feat(server): add auth, user, and studio modules
Auth: WeChat login, JWT, roles guard (24 tests passing) User: profile CRUD, training stats with month/total calculations Studio: config management with auto-default creation
This commit is contained in:
220
packages/server/src/auth/__tests__/auth.service.spec.ts
Normal file
220
packages/server/src/auth/__tests__/auth.service.spec.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { UnauthorizedException } from '@nestjs/common'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { AuthService } from '../auth.service'
|
||||
import { WechatService } from '../wechat.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
|
||||
// ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
const OPENID = 'test_openid_123'
|
||||
const SESSION_KEY = 'test_session_key'
|
||||
const USER_ID = 'user-uuid-001'
|
||||
const JWT_TOKEN = 'signed.jwt.token'
|
||||
|
||||
const mockUser = {
|
||||
id: USER_ID,
|
||||
openid: OPENID,
|
||||
unionid: null,
|
||||
phone: null,
|
||||
nickname: '',
|
||||
avatarUrl: null,
|
||||
role: UserRole.MEMBER,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
// ─── Mocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockPrismaService = {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
findUniqueOrThrow: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
}
|
||||
|
||||
const mockWechatService = {
|
||||
code2Session: jest.fn(),
|
||||
decryptData: jest.fn(),
|
||||
}
|
||||
|
||||
const mockJwtService = {
|
||||
sign: jest.fn(),
|
||||
}
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AuthService', () => {
|
||||
let authService: AuthService
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AuthService,
|
||||
{ provide: PrismaService, useValue: mockPrismaService },
|
||||
{ provide: WechatService, useValue: mockWechatService },
|
||||
{ provide: JwtService, useValue: mockJwtService },
|
||||
],
|
||||
}).compile()
|
||||
|
||||
authService = module.get<AuthService>(AuthService)
|
||||
|
||||
jest.clearAllMocks()
|
||||
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
||||
})
|
||||
|
||||
// ── login ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('login', () => {
|
||||
const loginCode = 'wx_login_code_abc'
|
||||
|
||||
beforeEach(() => {
|
||||
mockWechatService.code2Session.mockResolvedValue({
|
||||
openid: OPENID,
|
||||
sessionKey: SESSION_KEY,
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a new user when openid is not found', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null)
|
||||
mockPrismaService.user.create.mockResolvedValue(mockUser)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
expect(mockWechatService.code2Session).toHaveBeenCalledWith(loginCode)
|
||||
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { openid: OPENID },
|
||||
})
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||
data: { openid: OPENID },
|
||||
})
|
||||
expect(result.user).toEqual(mockUser)
|
||||
})
|
||||
|
||||
it('creates user with unionid when present', async () => {
|
||||
const unionid = 'wx_union_id_xyz'
|
||||
mockWechatService.code2Session.mockResolvedValue({
|
||||
openid: OPENID,
|
||||
sessionKey: SESSION_KEY,
|
||||
unionid,
|
||||
})
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null)
|
||||
mockPrismaService.user.create.mockResolvedValue({ ...mockUser, unionid })
|
||||
|
||||
await authService.login(loginCode)
|
||||
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
|
||||
data: { openid: OPENID, unionid },
|
||||
})
|
||||
})
|
||||
|
||||
it('returns existing user when openid already exists', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
expect(mockPrismaService.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { openid: OPENID },
|
||||
})
|
||||
expect(mockPrismaService.user.create).not.toHaveBeenCalled()
|
||||
expect(result.user).toEqual(mockUser)
|
||||
})
|
||||
|
||||
it('returns a valid JWT token', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
expect(mockJwtService.sign).toHaveBeenCalledWith({
|
||||
sub: USER_ID,
|
||||
role: UserRole.MEMBER,
|
||||
})
|
||||
expect(result.token).toBe(JWT_TOKEN)
|
||||
})
|
||||
|
||||
it('returns both token and user in result', async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
|
||||
const result = await authService.login(loginCode)
|
||||
|
||||
expect(result).toEqual({
|
||||
token: JWT_TOKEN,
|
||||
user: mockUser,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── bindPhone ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bindPhone', () => {
|
||||
const encryptedData = 'encrypted_phone_data'
|
||||
const iv = 'init_vector'
|
||||
const phoneNumber = '+8613800138000'
|
||||
|
||||
beforeEach(async () => {
|
||||
// Seed the in-memory session key store by running login first
|
||||
mockWechatService.code2Session.mockResolvedValue({
|
||||
openid: OPENID,
|
||||
sessionKey: SESSION_KEY,
|
||||
})
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser)
|
||||
mockJwtService.sign.mockReturnValue(JWT_TOKEN)
|
||||
await authService.login('login_code')
|
||||
})
|
||||
|
||||
it('updates phone number and returns the updated user', async () => {
|
||||
const updatedUser = { ...mockUser, phone: phoneNumber }
|
||||
|
||||
mockWechatService.decryptData.mockReturnValue({
|
||||
phoneNumber,
|
||||
purePhoneNumber: '13800138000',
|
||||
countryCode: '86',
|
||||
})
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser)
|
||||
|
||||
const result = await authService.bindPhone(USER_ID, encryptedData, iv)
|
||||
|
||||
expect(mockWechatService.decryptData).toHaveBeenCalledWith(
|
||||
SESSION_KEY,
|
||||
encryptedData,
|
||||
iv,
|
||||
)
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith({
|
||||
where: { id: USER_ID },
|
||||
data: { phone: phoneNumber },
|
||||
})
|
||||
expect(result).toEqual(updatedUser)
|
||||
})
|
||||
|
||||
it('throws UnauthorizedException when session key is not found', async () => {
|
||||
const unknownUserId = 'unknown-user-id'
|
||||
|
||||
await expect(
|
||||
authService.bindPhone(unknownUserId, encryptedData, iv),
|
||||
).rejects.toThrow(UnauthorizedException)
|
||||
})
|
||||
|
||||
it('does not mutate the original user object', async () => {
|
||||
const originalUser = { ...mockUser }
|
||||
const updatedUser = { ...mockUser, phone: phoneNumber }
|
||||
|
||||
mockWechatService.decryptData.mockReturnValue({
|
||||
phoneNumber,
|
||||
purePhoneNumber: '13800138000',
|
||||
countryCode: '86',
|
||||
})
|
||||
mockPrismaService.user.update.mockResolvedValue(updatedUser)
|
||||
|
||||
const result = await authService.bindPhone(USER_ID, encryptedData, iv)
|
||||
|
||||
// Original mock user should be unchanged
|
||||
expect(mockUser.phone).toBeNull()
|
||||
// Result is a new object with the updated phone
|
||||
expect(result.phone).toBe(phoneNumber)
|
||||
expect(result).not.toBe(originalUser)
|
||||
})
|
||||
})
|
||||
})
|
||||
44
packages/server/src/auth/auth.controller.ts
Normal file
44
packages/server/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common'
|
||||
import { AuthService } from './auth.service'
|
||||
import { LoginDto } from './dto/login.dto'
|
||||
import { BindPhoneDto } from './dto/bind-phone.dto'
|
||||
import { JwtAuthGuard } from './jwt-auth.guard'
|
||||
import { AuthenticatedUser } from './jwt.strategy'
|
||||
import { User } from '@prisma/client'
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: AuthenticatedUser
|
||||
}
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Post('login')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async login(@Body() loginDto: LoginDto): Promise<{ token: string; user: User }> {
|
||||
return this.authService.login(loginDto.code)
|
||||
}
|
||||
|
||||
@Post('phone')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.OK)
|
||||
async bindPhone(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Body() bindPhoneDto: BindPhoneDto,
|
||||
): Promise<User> {
|
||||
return this.authService.bindPhone(
|
||||
req.user.userId,
|
||||
bindPhoneDto.encryptedData,
|
||||
bindPhoneDto.iv,
|
||||
)
|
||||
}
|
||||
}
|
||||
28
packages/server/src/auth/auth.module.ts
Normal file
28
packages/server/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { PassportModule } from '@nestjs/passport'
|
||||
import { JwtModule } from '@nestjs/jwt'
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config'
|
||||
import { AuthService } from './auth.service'
|
||||
import { AuthController } from './auth.controller'
|
||||
import { WechatService } from './wechat.service'
|
||||
import { JwtStrategy } from './jwt.strategy'
|
||||
import { JwtAuthGuard } from './jwt-auth.guard'
|
||||
import { RolesGuard } from './roles.guard'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule,
|
||||
JwtModule.registerAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (configService: ConfigService) => ({
|
||||
secret: configService.getOrThrow<string>('JWT_SECRET'),
|
||||
signOptions: { expiresIn: '7d' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, WechatService, JwtStrategy, JwtAuthGuard, RolesGuard],
|
||||
exports: [JwtStrategy, JwtAuthGuard, RolesGuard, AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
82
packages/server/src/auth/auth.service.ts
Normal file
82
packages/server/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common'
|
||||
import { JwtService } from '@nestjs/jwt'
|
||||
import { User } from '@prisma/client'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { WechatService } from './wechat.service'
|
||||
|
||||
export interface LoginResult {
|
||||
token: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: string
|
||||
role: UserRole
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory session key store.
|
||||
* TODO: Replace with Redis for production multi-instance deployments.
|
||||
* Key: userId, Value: WeChat sessionKey
|
||||
*/
|
||||
const sessionKeyStore = new Map<string, string>()
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly wechatService: WechatService,
|
||||
) {}
|
||||
|
||||
async login(code: string): Promise<LoginResult> {
|
||||
const { openid, unionid, sessionKey } =
|
||||
await this.wechatService.code2Session(code)
|
||||
|
||||
const existingUser = await this.prisma.user.findUnique({
|
||||
where: { openid },
|
||||
})
|
||||
|
||||
const user =
|
||||
existingUser ??
|
||||
(await this.prisma.user.create({
|
||||
data: {
|
||||
openid,
|
||||
...(unionid !== undefined && { unionid }),
|
||||
},
|
||||
}))
|
||||
|
||||
sessionKeyStore.set(user.id, sessionKey)
|
||||
|
||||
const payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
|
||||
const token = this.jwtService.sign(payload)
|
||||
|
||||
return { token, user }
|
||||
}
|
||||
|
||||
async bindPhone(
|
||||
userId: string,
|
||||
encryptedData: string,
|
||||
iv: string,
|
||||
): Promise<User> {
|
||||
const sessionKey = sessionKeyStore.get(userId)
|
||||
|
||||
if (!sessionKey) {
|
||||
throw new UnauthorizedException(
|
||||
'Session expired. Please log in again to bind your phone number.',
|
||||
)
|
||||
}
|
||||
|
||||
const phoneInfo = this.wechatService.decryptData(
|
||||
sessionKey,
|
||||
encryptedData,
|
||||
iv,
|
||||
)
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { phone: phoneInfo.phoneNumber },
|
||||
})
|
||||
}
|
||||
}
|
||||
11
packages/server/src/auth/dto/bind-phone.dto.ts
Normal file
11
packages/server/src/auth/dto/bind-phone.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsString, IsNotEmpty } from 'class-validator'
|
||||
|
||||
export class BindPhoneDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
encryptedData!: string
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
iv!: string
|
||||
}
|
||||
7
packages/server/src/auth/dto/login.dto.ts
Normal file
7
packages/server/src/auth/dto/login.dto.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { IsString, IsNotEmpty } from 'class-validator'
|
||||
|
||||
export class LoginDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
code!: string
|
||||
}
|
||||
5
packages/server/src/auth/jwt-auth.guard.ts
Normal file
5
packages/server/src/auth/jwt-auth.guard.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { AuthGuard } from '@nestjs/passport'
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {}
|
||||
29
packages/server/src/auth/jwt.strategy.ts
Normal file
29
packages/server/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common'
|
||||
import { PassportStrategy } from '@nestjs/passport'
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { JwtPayload } from './auth.service'
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
userId: string
|
||||
role: UserRole
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(configService: ConfigService) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: configService.getOrThrow<string>('JWT_SECRET'),
|
||||
})
|
||||
}
|
||||
|
||||
validate(payload: JwtPayload): AuthenticatedUser {
|
||||
return {
|
||||
userId: payload.sub,
|
||||
role: payload.role,
|
||||
}
|
||||
}
|
||||
}
|
||||
6
packages/server/src/auth/roles.decorator.ts
Normal file
6
packages/server/src/auth/roles.decorator.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from '@nestjs/common'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
|
||||
export const ROLES_KEY = 'roles'
|
||||
|
||||
export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles)
|
||||
33
packages/server/src/auth/roles.guard.ts
Normal file
33
packages/server/src/auth/roles.guard.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'
|
||||
import { Reflector } from '@nestjs/core'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { ROLES_KEY } from './roles.decorator'
|
||||
import { AuthenticatedUser } from './jwt.strategy'
|
||||
|
||||
@Injectable()
|
||||
export class RolesGuard implements CanActivate {
|
||||
constructor(private readonly reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(
|
||||
ROLES_KEY,
|
||||
[context.getHandler(), context.getClass()],
|
||||
)
|
||||
|
||||
if (!requiredRoles || requiredRoles.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<{
|
||||
user?: AuthenticatedUser
|
||||
}>()
|
||||
|
||||
const user = request.user
|
||||
|
||||
if (!user) {
|
||||
return false
|
||||
}
|
||||
|
||||
return requiredRoles.includes(user.role)
|
||||
}
|
||||
}
|
||||
107
packages/server/src/auth/wechat.service.ts
Normal file
107
packages/server/src/auth/wechat.service.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Injectable, HttpException, HttpStatus } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import * as crypto from 'crypto'
|
||||
|
||||
export interface Code2SessionResult {
|
||||
openid: string
|
||||
sessionKey: string
|
||||
unionid?: string
|
||||
}
|
||||
|
||||
export interface WechatPhoneInfo {
|
||||
phoneNumber: string
|
||||
purePhoneNumber: string
|
||||
countryCode: string
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WechatService {
|
||||
private readonly appId: string
|
||||
private readonly secret: string
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.appId = this.configService.getOrThrow<string>('WX_APPID')
|
||||
this.secret = this.configService.getOrThrow<string>('WX_SECRET')
|
||||
}
|
||||
|
||||
async code2Session(code: string): Promise<Code2SessionResult> {
|
||||
const url =
|
||||
`https://api.weixin.qq.com/sns/jscode2session` +
|
||||
`?appid=${this.appId}` +
|
||||
`&secret=${this.secret}` +
|
||||
`&js_code=${code}` +
|
||||
`&grant_type=authorization_code`
|
||||
|
||||
const response = await fetch(url)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new HttpException(
|
||||
'WeChat API request failed',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
openid?: string
|
||||
session_key?: string
|
||||
unionid?: string
|
||||
errcode?: number
|
||||
errmsg?: string
|
||||
}
|
||||
|
||||
if (data.errcode) {
|
||||
throw new HttpException(
|
||||
`WeChat login error: ${data.errmsg ?? data.errcode}`,
|
||||
HttpStatus.UNAUTHORIZED,
|
||||
)
|
||||
}
|
||||
|
||||
if (!data.openid || !data.session_key) {
|
||||
throw new HttpException(
|
||||
'Invalid response from WeChat API',
|
||||
HttpStatus.BAD_GATEWAY,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
openid: data.openid,
|
||||
sessionKey: data.session_key,
|
||||
...(data.unionid !== undefined && { unionid: data.unionid }),
|
||||
}
|
||||
}
|
||||
|
||||
decryptData(
|
||||
sessionKey: string,
|
||||
encryptedData: string,
|
||||
iv: string,
|
||||
): WechatPhoneInfo {
|
||||
const sessionKeyBuffer = Buffer.from(sessionKey, 'base64')
|
||||
const encryptedDataBuffer = Buffer.from(encryptedData, 'base64')
|
||||
const ivBuffer = Buffer.from(iv, 'base64')
|
||||
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-128-cbc',
|
||||
sessionKeyBuffer,
|
||||
ivBuffer,
|
||||
)
|
||||
decipher.setAutoPadding(true)
|
||||
|
||||
const decryptedBuffer = Buffer.concat([
|
||||
decipher.update(encryptedDataBuffer),
|
||||
decipher.final(),
|
||||
])
|
||||
|
||||
const decryptedText = decryptedBuffer.toString('utf8')
|
||||
const decryptedData = JSON.parse(decryptedText) as {
|
||||
phoneNumber: string
|
||||
purePhoneNumber: string
|
||||
countryCode: string
|
||||
}
|
||||
|
||||
return {
|
||||
phoneNumber: decryptedData.phoneNumber,
|
||||
purePhoneNumber: decryptedData.purePhoneNumber,
|
||||
countryCode: decryptedData.countryCode,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user