feat(server): auth server (#2773)

This commit is contained in:
LongYinan
2023-06-21 14:08:32 +08:00
committed by GitHub
parent 2698e7fd0d
commit 9b3fa43b81
36 changed files with 4089 additions and 2013 deletions
+10 -3
View File
@@ -49,10 +49,17 @@ class AuthGuard implements CanActivate {
if (!token) {
return false;
}
const [type, jwt] = token.split(' ') ?? [];
const claims = this.auth.verify(token);
req.user = await this.prisma.user.findUnique({ where: { id: claims.id } });
return !!req.user;
if (type === 'Bearer') {
const claims = await this.auth.verify(jwt);
req.user = await this.prisma.user.findUnique({
where: { id: claims.id },
});
return !!req.user;
}
return false;
}
}
+2
View File
@@ -1,5 +1,6 @@
import { Global, Module } from '@nestjs/common';
import { NextAuthController } from './next-auth.controller';
import { AuthResolver } from './resolver';
import { AuthService } from './service';
@@ -7,6 +8,7 @@ import { AuthService } from './service';
@Module({
providers: [AuthService, AuthResolver],
exports: [AuthService],
controllers: [NextAuthController],
})
export class AuthModule {}
export * from './guard';
@@ -0,0 +1,158 @@
import { randomUUID } from 'node:crypto';
import { PrismaAdapter } from '@auth/prisma-adapter';
import {
BadRequestException,
Controller,
Get,
Next,
Post,
Query,
Req,
Res,
} from '@nestjs/common';
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import type { NextFunction, Request, Response } from 'express';
import type { AuthAction, AuthOptions } from 'next-auth';
import { AuthHandler } from 'next-auth/core';
import Github from 'next-auth/providers/github';
import Google from 'next-auth/providers/google';
import { Config } from '../../config';
import { PrismaService } from '../../prisma/service';
import { getUtcTimestamp, type UserClaim } from './service';
const BASE_URL = '/api/auth/';
@Controller(BASE_URL)
export class NextAuthController {
private readonly nextAuthOptions: AuthOptions;
constructor(readonly config: Config, readonly prisma: PrismaService) {
this.nextAuthOptions = {
providers: [],
// @ts-expect-error Third part library type mismatch
adapter: PrismaAdapter(prisma),
};
if (config.auth.oauthProviders.github) {
this.nextAuthOptions.providers.push(
Github({
clientId: config.auth.oauthProviders.github.clientId,
clientSecret: config.auth.oauthProviders.github.clientSecret,
})
);
}
if (config.auth.oauthProviders.google) {
this.nextAuthOptions.providers.push(
Google({
clientId: config.auth.oauthProviders.google.clientId,
clientSecret: config.auth.oauthProviders.google.clientSecret,
})
);
}
this.nextAuthOptions.jwt = {
encode: async ({ token, maxAge }) => {
if (!token?.email) {
throw new BadRequestException('Missing email in jwt token');
}
const user = await this.prisma.user.findFirstOrThrow({
where: {
email: token.email,
},
});
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString(),
},
iat: now,
exp: now + (maxAge ?? config.auth.accessTokenExpiresIn),
iss: this.config.serverId,
sub: user.id,
aud: user.name,
jti: randomUUID({
disableEntropyCache: true,
}),
},
this.config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
},
decode: async ({ token }) => {
if (!token) {
return null;
}
const { name, email, id } = (
await jwtVerify(token, this.config.auth.publicKey, {
algorithms: [Algorithm.ES256],
iss: [this.config.serverId],
leeway: this.config.auth.leeway,
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
})
).data as UserClaim;
return {
name,
email,
sub: id,
};
},
};
this.nextAuthOptions.secret ??= config.auth.nextAuthSecret;
}
@Get()
@Post()
async auth(
@Req() req: Request,
@Res() res: Response,
@Query() query: Record<string, any>,
@Next() next: NextFunction
) {
const nextauth = req.url // start with request url
.slice(BASE_URL.length) // make relative to baseUrl
.replace(/\?.*/, '') // remove query part, use only path part
.split('/') as AuthAction[]; // as array of strings;
const { status, headers, body, redirect, cookies } = await AuthHandler({
req: {
body: req.body,
query: query,
method: req.method,
action: nextauth[0],
providerId: nextauth[1],
error: query.error ?? nextauth[1],
cookies: req.cookies,
},
options: this.nextAuthOptions,
});
if (status) {
res.status(status);
}
if (headers) {
for (const { key, value } of headers) {
res.setHeader(key, value);
}
}
if (cookies) {
for (const cookie of cookies) {
res.cookie(cookie.name, cookie.value, cookie.options);
}
}
if (redirect) {
res.redirect(redirect);
} else if (typeof body === 'string') {
res.send(body);
} else if (body && typeof body === 'object') {
res.json(body);
} else {
next();
}
}
}
+12
View File
@@ -50,4 +50,16 @@ export class AuthResolver {
ctx.req.user = user;
return user;
}
@Mutation(() => UserType)
async signUp(
@Context() ctx: { req: Request },
@Args('email') email: string,
@Args('password') password: string,
@Args('name') name: string
) {
const user = await this.auth.register(name, email, password);
ctx.req.user = user;
return user;
}
}
+72 -24
View File
@@ -1,44 +1,88 @@
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
Injectable,
InternalServerErrorException,
UnauthorizedException,
} from '@nestjs/common';
import { compare, hash } from '@node-rs/bcrypt';
import { hash, verify } from '@node-rs/argon2';
import { Algorithm, sign, verify as jwtVerify } from '@node-rs/jsonwebtoken';
import type { User } from '@prisma/client';
import jwt from 'jsonwebtoken';
import { Config } from '../../config';
import { PrismaService } from '../../prisma';
type UserClaim = Pick<User, 'id' | 'name' | 'email'>;
export type UserClaim = Pick<User, 'id' | 'name' | 'email' | 'createdAt'>;
export const getUtcTimestamp = () => Math.floor(new Date().getTime() / 1000);
@Injectable()
export class AuthService {
constructor(private config: Config, private prisma: PrismaService) {}
sign(user: UserClaim) {
return jwt.sign(user, this.config.auth.privateKey, {
algorithm: 'ES256',
subject: user.id,
issuer: this.config.serverId,
expiresIn: this.config.auth.accessTokenExpiresIn,
});
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString(),
},
iat: now,
exp: now + this.config.auth.accessTokenExpiresIn,
iss: this.config.serverId,
sub: user.id,
aud: user.name,
jti: randomUUID({
disableEntropyCache: true,
}),
},
this.config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
}
refresh(user: UserClaim) {
return jwt.sign(user, this.config.auth.privateKey, {
algorithm: 'ES256',
subject: user.id,
issuer: this.config.serverId,
expiresIn: this.config.auth.refreshTokenExpiresIn,
});
const now = getUtcTimestamp();
return sign(
{
data: {
id: user.id,
name: user.name,
email: user.email,
createdAt: user.createdAt.toISOString(),
},
exp: now + this.config.auth.refreshTokenExpiresIn,
iat: now,
iss: this.config.serverId,
sub: user.id,
aud: user.name,
jti: randomUUID({
disableEntropyCache: true,
}),
},
this.config.auth.privateKey,
{
algorithm: Algorithm.ES256,
}
);
}
verify(token: string) {
async verify(token: string) {
try {
return jwt.verify(token, this.config.auth.publicKey, {
algorithms: ['ES256'],
}) as UserClaim;
return (
await jwtVerify(token, this.config.auth.publicKey, {
algorithms: [Algorithm.ES256],
iss: [this.config.serverId],
leeway: this.config.auth.leeway,
requiredSpecClaims: ['exp', 'iat', 'iss', 'sub'],
})
).data as UserClaim;
} catch (e) {
throw new UnauthorizedException('Invalid token');
}
@@ -58,9 +102,13 @@ export class AuthService {
if (!user.password) {
throw new BadRequestException('User has no password');
}
const equal = await compare(password, user.password);
let equal = false;
try {
equal = await verify(user.password, password);
} catch (e) {
console.error(e);
throw new InternalServerErrorException(e, 'Verify password failed');
}
if (!equal) {
throw new UnauthorizedException('Invalid password');
}
@@ -69,8 +117,6 @@ export class AuthService {
}
async register(name: string, email: string, password: string): Promise<User> {
const hashedPassword = await hash(password);
const user = await this.prisma.user.findFirst({
where: {
email,
@@ -81,6 +127,8 @@ export class AuthService {
throw new BadRequestException('Email already exists');
}
const hashedPassword = await hash(password);
return this.prisma.user.create({
data: {
name,