# 第十二堂:JWT 身份驗證機制 - 錄影 - API 應用可參考[線稿圖](https://miro.com/app/board/uXjVLbiDml0=/) - API 開發規格請依照 [此 API 文件](https://liberating-turtle-5a2.notion.site/38e4dd6775894e94a8f575f20ca5b867?v=17c6a246851881208205000cacf67220) - db 欄位設計請依照 [此資料庫欄位圖片](https://firebasestorage.googleapis.com/v0/b/hexschool-courses.appspot.com/o/hex-website%2Fnode%2F1739179853094-fitness_5.png?alt=media&token=a65de209-3ae6-4263-bdc0-d4f08638ff20) 定義 ## JWT 機制介紹 * [JWT 圖解](https://whimsical.com/jwt-UKUY1rj1vfoN6uyic7e4Sm@2Ux7TurymNEtgWEBxMsZ)、[官網介紹](https://jwt.io/) * [base64 解碼](https://www.convertstring.com/zh_TW/EncodeDecode/Base64Decode) ```=javascript <!-- 產生 JWT token:payload、secret、option --> jwt.sign({id:user._id},secret,{ expiresIn: "90d" }) // output:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjYyNDk0NzUzMzI4OTMzMDM0NmYxMDkyNCIsImlhdCI6MTY0ODk2OTU1NSwiZXhwIjoxNzEyMDQxNTU1fQ.bjUj8QgQao6pgFE1MMzZeiD4q0d-VJN0a5CznJr4ZnE <!-- 解密 JWT --> jwt.verify(token, secretOrPublicKey, [options, callback]) // output { "id": "6249368f98f7f965568ad019", "iat": 1648968184, "exp": 1712040184 } ``` ## 環境建立 1. 確認 env 2. 加入 NPM `jsonwebtoken` 3. 加入 config/**secret.js,並在 index.js 也增加** ```jsx= module.exports = { jwtSecret: process.env.JWT_SECRET, jwtExpiresDay: process.env.JWT_EXPIRES_DAY } ``` ### utils/generateJWT.js ```jsx= const jwt = require('jsonwebtoken') /** * create JSON Web Token * @param {Object} payload token content * @param {String} secret token secret * @param {Object} [option] same to npm package - jsonwebtoken * @returns {String} */ module.exports = (payload, secret, option = {}) => new Promise((resolve, reject) => { jwt.sign(payload, secret, option, (err, token) => { if (err) { reject(err) return } resolve(token) }) }) ``` ### middlwares>auth.js ```jsx= const jwt = require('jsonwebtoken') const PERMISSION_DENIED_STATUS_CODE = 401 const FailedMessageMap = { expired: 'Token 已過期', invalid: '無效的 token', missing: '請先登入' } function generateError (status, message) { const error = new Error(message) error.status = status return error } function formatVerifyError (jwtError) { let result switch (jwtError.name) { case 'TokenExpiredError': result = generateError(PERMISSION_DENIED_STATUS_CODE, FailedMessageMap.expired) break default: result = generateError(PERMISSION_DENIED_STATUS_CODE, FailedMessageMap.invalid) break } return result } function verifyJWT (token, secret) { return new Promise((resolve, reject) => { jwt.verify(token, secret, (error, decoded) => { if (error) { reject(formatVerifyError(error)) } else { resolve(decoded) } }) }) } module.exports = ({ secret, userRepository, logger = console }) => { if (!secret || typeof secret !== 'string') { logger.error('[AuthV2] secret is required and must be a string.') throw new Error('[AuthV2] secret is required and must be a string.') } if (!userRepository || typeof userRepository !== 'object' || typeof userRepository.findOneBy !== 'function') { logger.error('[AuthV2] userRepository is required and must be a function.') throw new Error('[AuthV2] userRepository is required and must be a function.') } return async (req, res, next) => { if ( !req.headers || !req.headers.authorization || !req.headers.authorization.startsWith('Bearer') ) { logger.warn('[AuthV2] Missing authorization header.') next(generateError(PERMISSION_DENIED_STATUS_CODE, FailedMessageMap.missing)) return } const [, token] = req.headers.authorization.split(' ') if (!token) { logger.warn('[AuthV2] Missing token.') next(generateError(PERMISSION_DENIED_STATUS_CODE, FailedMessageMap.missing)) return } try { const verifyResult = await verifyJWT(token, secret) const user = await userRepository.findOneBy({ id: verifyResult.id }) if (!user) { next(generateError(PERMISSION_DENIED_STATUS_CODE, FailedMessageMap.invalid)) return } req.user = user next() } catch (error) { logger.error(`[AuthV2] ${error.message}`) next(error) } } } ``` ### middlewares/isCoach.js ```jsx= const FORBIDDEN_MESSAGE = '使用者尚未成為教練' const PERMISSION_DENIED_STATUS_CODE = 401 function generateError (status = PERMISSION_DENIED_STATUS_CODE, message = FORBIDDEN_MESSAGE) { const error = new Error(message) error.status = status return error } module.exports = (req, res, next) => { if (!req.user || req.user.role !== 'COACH') { next(generateError()) return } next() } ``` ### routes/user ```jsx= const express = require('express') const bcrypt = require('bcrypt') const router = express.Router() const config = require('../config/index') const { dataSource } = require('../db/data-source') const logger = require('../utils/logger')('Users') const generateJWT = require('../utils/generateJWT') const auth = require('../middlewares/auth')({ secret: config.get('secret').jwtSecret, userRepository: dataSource.getRepository('User'), logger }) function isUndefined (value) { return value === undefined } function isNotValidSting (value) { return typeof value !== 'string' || value.trim().length === 0 || value === '' } router.post('/signup', async (req, res, next) => { try { const passwordPattern = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,16}/ const { name, email, password } = req.body if (isUndefined(name) || isNotValidSting(name) || isUndefined(email) || isNotValidSting(email) || isUndefined(password) || isNotValidSting(password)) { logger.warn('欄位未填寫正確') res.status(400).json({ status: 'failed', message: '欄位未填寫正確' }) return } if (!passwordPattern.test(password)) { logger.warn('建立使用者錯誤: 密碼不符合規則,需要包含英文數字大小寫,最短8個字,最長16個字') res.status(400).json({ status: 'failed', message: '密碼不符合規則,需要包含英文數字大小寫,最短8個字,最長16個字' }) return } const userRepository = dataSource.getRepository('User') const existingUser = await userRepository.findOne({ where: { email } }) if (existingUser) { logger.warn('建立使用者錯誤: Email 已被使用') res.status(409).json({ status: 'failed', message: 'Email 已被使用' }) return } const salt = await bcrypt.genSalt(10) const hashPassword = await bcrypt.hash(password, salt) const newUser = userRepository.create({ name, email, role: 'USER', password: hashPassword }) const savedUser = await userRepository.save(newUser) logger.info('新建立的使用者ID:', savedUser.id) res.status(201).json({ status: 'success', data: { user: { id: savedUser.id, name: savedUser.name } } }) } catch (error) { logger.error('建立使用者錯誤:', error) next(error) } }) router.post('/login', async (req, res, next) => { try { const passwordPattern = /(?=.*\d)(?=.*[a-z])(?=.*[A-Z]).{8,16}/ const { email, password } = req.body if (isUndefined(email) || isNotValidSting(email) || isUndefined(password) || isNotValidSting(password)) { logger.warn('欄位未填寫正確') res.status(400).json({ status: 'failed', message: '欄位未填寫正確' }) return } if (!passwordPattern.test(password)) { logger.warn('密碼不符合規則,需要包含英文數字大小寫,最短8個字,最長16個字') res.status(400).json({ status: 'failed', message: '密碼不符合規則,需要包含英文數字大小寫,最短8個字,最長16個字' }) return } const userRepository = dataSource.getRepository('User') const existingUser = await userRepository.findOne({ select: ['id', 'name', 'password'], where: { email } }) if (!existingUser) { res.status(400).json({ status: 'failed', message: '使用者不存在或密碼輸入錯誤' }) return } logger.info(`使用者資料: ${JSON.stringify(existingUser)}`) const isMatch = await bcrypt.compare(password, existingUser.password) if (!isMatch) { res.status(400).json({ status: 'failed', message: '使用者不存在或密碼輸入錯誤' }) return } const token = await generateJWT({ id: existingUser.id }, config.get('secret.jwtSecret'), { expiresIn: `${config.get('secret.jwtExpiresDay')}` }) res.status(201).json({ status: 'success', data: { token, user: { name: existingUser.name } } }) } catch (error) { logger.error('登入錯誤:', error) next(error) } }) router.get('/profile', auth, async (req, res, next) => { try { const { id } = req.user const userRepository = dataSource.getRepository('User') const user = await userRepository.findOne({ select: ['name', 'email'], where: { id } }) res.status(200).json({ status: 'success', data: { user } }) } catch (error) { logger.error('取得使用者資料錯誤:', error) next(error) } }) router.put('/profile', auth, async (req, res, next) => { try { const { id } = req.user const { name } = req.body if (isUndefined(name) || isNotValidSting(name)) { logger.warn('欄位未填寫正確') res.status(400).json({ status: 'failed', message: '欄位未填寫正確' }) return } const userRepository = dataSource.getRepository('User') const user = await userRepository.findOne({ select: ['name'], where: { id } }) if (user.name === name) { res.status(400).json({ status: 'failed', message: '使用者名稱未變更' }) return } const updatedResult = await userRepository.update({ id, name: user.name }, { name }) if (updatedResult.affected === 0) { res.status(400).json({ status: 'failed', message: '更新使用者資料失敗' }) return } const result = await userRepository.findOne({ select: ['name'], where: { id } }) res.status(200).json({ status: 'success', data: { user: result } }) } catch (error) { logger.error('取得使用者資料錯誤:', error) next(error) } }) module.exports = router ``` ## 中場休息 ## routes/admin 教練身份加入登入條件 ```jsx= const express = require('express') const router = express.Router() const config = require('../config/index') const { dataSource } = require('../db/data-source') const logger = require('../utils/logger')('Admin') const auth = require('../middlewares/auth')({ secret: config.get('secret').jwtSecret, userRepository: dataSource.getRepository('User'), logger }) const isCoach = require('../middlewares/isCoach') function isUndefined (value) { return value === undefined } function isNotValidSting (value) { return typeof value !== 'string' || value.trim().length === 0 || value === '' } function isNotValidInteger (value) { return typeof value !== 'number' || value < 0 || value % 1 !== 0 } router.post('/coaches/courses', auth, isCoach, async (req, res, next) => { try { const { id } = req.user const { skill_id: skillId, name, description, start_at: startAt, end_at: endAt, max_participants: maxParticipants, meeting_url: meetingUrl } = req.body if (isUndefined(skillId) || isNotValidSting(skillId) || isUndefined(name) || isNotValidSting(name) || isUndefined(description) || isNotValidSting(description) || isUndefined(startAt) || isNotValidSting(startAt) || isUndefined(endAt) || isNotValidSting(endAt) || isUndefined(maxParticipants) || isNotValidInteger(maxParticipants) || isUndefined(meetingUrl) || isNotValidSting(meetingUrl) || !meetingUrl.startsWith('https')) { logger.warn('欄位未填寫正確') res.status(400).json({ status: 'failed', message: '欄位未填寫正確' }) return } const courseRepo = dataSource.getRepository('Course') const newCourse = courseRepo.create({ user_id: id, skill_id: skillId, name, description, start_at: startAt, end_at: endAt, max_participants: maxParticipants, meeting_url: meetingUrl }) const savedCourse = await courseRepo.save(newCourse) const course = await courseRepo.findOne({ where: { id: savedCourse.id } }) res.status(201).json({ status: 'success', data: { course } }) } catch (error) { logger.error(error) next(error) } }) router.put('/coaches/courses/:courseId', auth, isCoach, async (req, res, next) => { try { const { id } = req.user const { courseId } = req.params const { skill_id: skillId, name, description, start_at: startAt, end_at: endAt, max_participants: maxParticipants, meeting_url: meetingUrl } = req.body if (isNotValidSting(courseId) || isUndefined(skillId) || isNotValidSting(skillId) || isUndefined(name) || isNotValidSting(name) || isUndefined(description) || isNotValidSting(description) || isUndefined(startAt) || isNotValidSting(startAt) || isUndefined(endAt) || isNotValidSting(endAt) || isUndefined(maxParticipants) || isNotValidInteger(maxParticipants) || isUndefined(meetingUrl) || isNotValidSting(meetingUrl) || !meetingUrl.startsWith('https')) { logger.warn('欄位未填寫正確') res.status(400).json({ status: 'failed', message: '欄位未填寫正確' }) return } const courseRepo = dataSource.getRepository('Course') const existingCourse = await courseRepo.findOne({ where: { id: courseId, user_id: id } }) if (!existingCourse) { logger.warn('課程不存在') res.status(400).json({ status: 'failed', message: '課程不存在' }) return } const updateCourse = await courseRepo.update({ id: courseId }, { skill_id: skillId, name, description, start_at: startAt, end_at: endAt, max_participants: maxParticipants, meeting_url: meetingUrl }) if (updateCourse.affected === 0) { logger.warn('更新課程失敗') res.status(400).json({ status: 'failed', message: '更新課程失敗' }) return } const savedCourse = await courseRepo.findOne({ where: { id: courseId } }) res.status(200).json({ status: 'success', data: { course: savedCourse } }) } catch (error) { logger.error(error) next(error) } }) router.post('/coaches/:userId', async (req, res, next) => { try { const { userId } = req.params const { experience_years: experienceYears, description, profile_image_url: profileImageUrl = null } = req.body if (isUndefined(experienceYears) || isNotValidInteger(experienceYears) || isUndefined(description) || isNotValidSting(description)) { logger.warn('欄位未填寫正確') res.status(400).json({ status: 'failed', message: '欄位未填寫正確' }) return } if (profileImageUrl && !isNotValidSting(profileImageUrl) && !profileImageUrl.startsWith('https')) { logger.warn('大頭貼網址錯誤') res.status(400).json({ status: 'failed', message: '欄位未填寫正確' }) return } const userRepository = dataSource.getRepository('User') const existingUser = await userRepository.findOne({ select: ['id', 'name', 'role'], where: { id: userId } }) if (!existingUser) { logger.warn('使用者不存在') res.status(400).json({ status: 'failed', message: '使用者不存在' }) return } else if (existingUser.role === 'COACH') { logger.warn('使用者已經是教練') res.status(409).json({ status: 'failed', message: '使用者已經是教練' }) return } const coachRepo = dataSource.getRepository('Coach') const newCoach = coachRepo.create({ user_id: userId, experience_years: experienceYears, description, profile_image_url: profileImageUrl }) const updatedUser = await userRepository.update({ id: userId, role: 'USER' }, { role: 'COACH' }) if (updatedUser.affected === 0) { logger.warn('更新使用者失敗') res.status(400).json({ status: 'failed', message: '更新使用者失敗' }) return } const savedCoach = await coachRepo.save(newCoach) const savedUser = await userRepository.findOne({ select: ['name', 'role'], where: { id: userId } }) res.status(201).json({ status: 'success', data: { user: savedUser, coach: savedCoach } }) } catch (error) { logger.error(error) next(error) } }) module.exports = router ``` ## 新增課程 ### entities/CreditPurchase.js ```jsx= const { EntitySchema } = require('typeorm') module.exports = new EntitySchema({ name: 'CreditPurchase', tableName: 'CREDIT_PURCHASE', columns: { id: { primary: true, type: 'uuid', generated: 'uuid', nullable: false }, user_id: { type: 'uuid', nullable: false }, credit_package_id: { type: 'uuid', nullable: false }, purchased_credits: { type: 'integer', nullable: false }, price_paid: { type: 'numeric', precision: 10, scale: 2, nullable: false }, createdAt: { type: 'timestamp', createDate: true, name: 'created_at', nullable: false }, purchaseAt: { type: 'timestamp', name: 'purchase_at', nullable: false } }, relations: { User: { target: 'User', type: 'many-to-one', joinColumn: { name: 'user_id', referencedColumnName: 'id', foreignKeyConstraintName: 'credit_purchase_user_id_fk' } }, CreditPackage: { target: 'CreditPackage', type: 'many-to-one', joinColumn: { name: 'credit_package_id', referencedColumnName: 'id', foreignKeyConstraintName: 'credit_purchase_credit_package_id_fk' } } } }) ``` ### entities/CourseBooking.js ```jsx= const { EntitySchema } = require('typeorm') module.exports = new EntitySchema({ name: 'CourseBooking', tableName: 'COURSE_BOOKING', columns: { id: { primary: true, type: 'uuid', generated: 'uuid', nullable: false }, user_id: { type: 'uuid', nullable: false }, course_id: { type: 'uuid', nullable: false }, bookingAt: { type: 'timestamp', createDate: true, name: 'booking_at', nullable: false }, joinAt: { type: 'timestamp', name: 'join_at', nullable: true }, leaveAt: { type: 'timestamp', name: 'leave_at', nullable: true }, cancelledAt: { type: 'timestamp', name: 'cancelled_at', nullable: true }, cancellation_reason: { type: 'varchar', length: 255, nullable: true }, createdAt: { type: 'timestamp', createDate: true, name: 'created_at', nullable: false } }, relations: { User: { target: 'User', type: 'many-to-one', joinColumn: { name: 'user_id', referencedColumnName: 'id', foreignKeyConstraintName: 'course_booking_user_id_fk' } }, CreditPackage: { target: 'Course', type: 'many-to-one', joinColumn: { name: 'course_id', referencedColumnName: 'id', foreignKeyConstraintName: 'course_booking_course_id_fk' } } } }) ``` > 加入到 db/datasource.js ### routes/creditPackage.js ```jsx= const express = require('express') const router = express.Router() const config = require('../config/index') const { dataSource } = require('../db/data-source') const logger = require('../utils/logger')('CreditPackage') const auth = require('../middlewares/auth')({ secret: config.get('secret').jwtSecret, userRepository: dataSource.getRepository('User'), logger }) function isUndefined (value) { return value === undefined } function isNotValidSting (value) { return typeof value !== 'string' || value.trim().length === 0 || value === '' } function isNotValidInteger (value) { return typeof value !== 'number' || value < 0 || value % 1 !== 0 } router.get('/', async (req, res, next) => { try { const creditPackage = await dataSource.getRepository('CreditPackage').find({ select: ['id', 'name', 'credit_amount', 'price'] }) res.status(200).json({ status: 'success', data: creditPackage }) } catch (error) { logger.error(error) next(error) } }) router.post('/', async (req, res, next) => { try { const { name, credit_amount: creditAmount, price } = req.body if (isUndefined(name) || isNotValidSting(name) || isUndefined(creditAmount) || isNotValidInteger(creditAmount) || isUndefined(price) || isNotValidInteger(price)) { res.status(400).json({ status: 'failed', message: '欄位未填寫正確' }) return } const creditPackageRepo = dataSource.getRepository('CreditPackage') const existCreditPackage = await creditPackageRepo.find({ where: { name } }) if (existCreditPackage.length > 0) { res.status(409).json({ status: 'failed', message: '資料重複' }) return } const newCreditPurchase = await creditPackageRepo.create({ name, credit_amount: creditAmount, price }) const result = await creditPackageRepo.save(newCreditPurchase) res.status(200).json({ status: 'success', data: result }) } catch (error) { logger.error(error) next(error) } }) router.post('/:creditPackageId', auth, async (req, res, next) => { try { const { id } = req.user const { creditPackageId } = req.params const creditPackageRepo = dataSource.getRepository('CreditPackage') const creditPackage = await creditPackageRepo.findOne({ where: { id: creditPackageId } }) if (!creditPackage) { res.status(400).json({ status: 'failed', message: 'ID錯誤' }) return } const creditPurchaseRepo = dataSource.getRepository('CreditPurchase') const newPurchase = await creditPurchaseRepo.create({ user_id: id, credit_package_id: creditPackageId, purchased_credits: creditPackage.credit_amount, price_paid: creditPackage.price, purchaseAt: new Date().toISOString() }) await creditPurchaseRepo.save(newPurchase) res.status(200).json({ status: 'success', data: null }) } catch (error) { logger.error(error) next(error) } }) router.delete('/:creditPackageId', async (req, res, next) => { try { const { creditPackageId } = req.params if (isUndefined(creditPackageId) || isNotValidSting(creditPackageId)) { res.status(400).json({ status: 'failed', message: '欄位未填寫正確' }) return } const result = await dataSource.getRepository('CreditPackage').delete(creditPackageId) if (result.affected === 0) { res.status(400).json({ status: 'failed', message: 'ID錯誤' }) return } res.status(200).json({ status: 'success', data: result }) } catch (error) { logger.error(error) next(error) } }) module.exports = router ``` ### routes/courses.js ```jsx= const express = require('express') const { IsNull } = require('typeorm') const router = express.Router() const config = require('../config/index') const { dataSource } = require('../db/data-source') const logger = require('../utils/logger')('Course') const auth = require('../middlewares/auth')({ secret: config.get('secret').jwtSecret, userRepository: dataSource.getRepository('User'), logger }) router.get('/', async (req, res, next) => { try { const courses = await dataSource.getRepository('Course').find({ select: { id: true, name: true, description: true, start_at: true, end_at: true, max_participants: true, User: { name: true }, Skill: { name: true } }, relations: { User: true, Skill: true } }) res.status(200).json({ status: 'success', data: courses.map((course) => { return { id: course.id, name: course.name, description: course.description, start_at: course.start_at, end_at: course.end_at, max_participants: course.max_participants, coach_name: course.User.name, skill_name: course.Skill.name } }) }) } catch (error) { logger.error(error) next(error) } }) router.post('/:courseId', auth, async (req, res, next) => { try { const { id } = req.user const { courseId } = req.params const courseRepo = dataSource.getRepository('Course') const course = await courseRepo.findOne({ where: { id: courseId } }) if (!course) { res.status(400).json({ status: 'failed', message: 'ID錯誤' }) return } const creditPurchaseRepo = dataSource.getRepository('CreditPurchase') const courseBookingRepo = dataSource.getRepository('CourseBooking') const userCourseBooking = await courseBookingRepo.findOne({ where: { user_id: id, course_id: courseId } }) if (userCourseBooking) { res.status(400).json({ status: 'failed', message: '已經報名過此課程' }) return } const userCredit = await creditPurchaseRepo.sum('purchased_credits', { user_id: id }) const userUsedCredit = await courseBookingRepo.count({ where: { user_id: id, cancelledAt: IsNull() } }) const courseBookingCount = await courseBookingRepo.count({ where: { course_id: courseId, cancelledAt: IsNull() } }) if (userUsedCredit >= userCredit) { res.status(400).json({ status: 'failed', message: '已無可使用堂數' }) return } else if (courseBookingCount >= course.max_participants) { res.status(400).json({ status: 'failed', message: '已達最大參加人數,無法參加' }) return } const newCourseBooking = await courseBookingRepo.create({ user_id: id, course_id: courseId }) await courseBookingRepo.save(newCourseBooking) res.status(201).json({ status: 'success', data: null }) } catch (error) { logger.error(error) next(error) } }) router.delete('/:courseId', auth, async (req, res, next) => { try { const { id } = req.user const { courseId } = req.params const courseBookingRepo = dataSource.getRepository('CourseBooking') const userCourseBooking = await courseBookingRepo.findOne({ where: { user_id: id, course_id: courseId, cancelledAt: IsNull() } }) if (!userCourseBooking) { res.status(400).json({ status: 'failed', message: 'ID錯誤' }) return } const updateResult = await courseBookingRepo.update( { user_id: id, course_id: courseId, cancelledAt: IsNull() }, { cancelledAt: new Date().toISOString() } ) if (updateResult.affected === 0) { res.status(400).json({ status: 'failed', message: '取消失敗' }) return } res.status(200).json({ status: 'success', data: null }) } catch (error) { logger.error(error) next(error) } }) module.exports = router ```