feat(auth): add OAuth2 login (#276)

* feat(auth): add OAuth2 login with GitHub and Google

* chore(translations): add files for Japanese

* fix(auth): fix link function for GitHub

* feat(oauth): basic oidc implementation

* feat(oauth): oauth guard

* fix: disable image optimizations for logo to prevent caching issues with custom logos

* fix: memory leak while downloading large files

* chore(translations): update translations via Crowdin (#278)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Japanese)

* New translations en-us.ts (Japanese)

* release: 0.18.2

* doc(translations): Add Japanese README (#279)

* Added Japanese README.

* Added JAPANESE README link to README.md.

* Updated Japanese README.

* Updated Environment Variable Table.

* updated zh-cn README.

* feat(oauth): unlink account

* refactor(oauth): make providers extensible

* fix(oauth): fix discoveryUri error when toggle google-enabled

* feat(oauth): add microsoft and discord as oauth provider

* docs(oauth): update README.md

* docs(oauth): update oauth2-guide.md

* set password to null for new oauth users

* New translations en-us.ts (Japanese) (#281)

* chore(translations): add Polish files

* fix(oauth): fix random username and password

* feat(oauth): add totp

* fix(oauth): fix totp throttle

* fix(oauth): fix qrcode and remove comment

* feat(oauth): add error page

* fix(oauth): i18n of error page

* feat(auth): add OAuth2 login

* fix(auth): fix link function for GitHub

* feat(oauth): basic oidc implementation

* feat(oauth): oauth guard

* feat(oauth): unlink account

* refactor(oauth): make providers extensible

* fix(oauth): fix discoveryUri error when toggle google-enabled

* feat(oauth): add microsoft and discord as oauth provider

* docs(oauth): update README.md

* docs(oauth): update oauth2-guide.md

* set password to null for new oauth users

* fix(oauth): fix random username and password

* feat(oauth): add totp

* fix(oauth): fix totp throttle

* fix(oauth): fix qrcode and remove comment

* feat(oauth): add error page

* fix(oauth): i18n of error page

* refactor: return null instead of `false` in `getIdOfCurrentUser` functiom

* feat: show original oauth error if available

* refactor: run formatter

* refactor(oauth): error message i18n

* refactor(oauth): make OAuth token available
someone may use it (to revoke token or get other info etc.)
also improved the i18n message

* chore(oauth): remove unused import

* chore: add database migration

* fix: missing python installation for nanoid

---------

Co-authored-by: Elias Schneider <login@eliasschneider.com>
Co-authored-by: ふうせん <10260662+fusengum@users.noreply.github.com>
This commit is contained in:
Qing Fu
2023-10-22 22:09:53 +08:00
committed by GitHub
parent d327bc355c
commit 02cd98fa9c
52 changed files with 1983 additions and 161 deletions

View File

@@ -47,7 +47,7 @@ export class AuthController {
const result = await this.authService.signUp(dto);
response = this.addTokensToResponse(
this.authService.addTokensToResponse(
response,
result.refreshToken,
result.accessToken,
@@ -66,7 +66,7 @@ export class AuthController {
const result = await this.authService.signIn(dto);
if (result.accessToken && result.refreshToken) {
response = this.addTokensToResponse(
this.authService.addTokensToResponse(
response,
result.refreshToken,
result.accessToken,
@@ -85,7 +85,7 @@ export class AuthController {
) {
const result = await this.authTotpService.signInTotp(dto);
response = this.addTokensToResponse(
this.authService.addTokensToResponse(
response,
result.refreshToken,
result.accessToken,
@@ -117,11 +117,11 @@ export class AuthController {
) {
const result = await this.authService.updatePassword(
user,
dto.oldPassword,
dto.password,
dto.oldPassword,
);
response = this.addTokensToResponse(response, result.refreshToken);
this.authService.addTokensToResponse(response, result.refreshToken);
return new TokenDTO().from(result);
}
@@ -136,7 +136,7 @@ export class AuthController {
const accessToken = await this.authService.refreshAccessToken(
request.cookies.refresh_token,
);
response = this.addTokensToResponse(response, undefined, accessToken);
this.authService.addTokensToResponse(response, undefined, accessToken);
return new TokenDTO().from({ accessToken });
}
@@ -172,22 +172,4 @@ export class AuthController {
// Note: We use VerifyTotpDTO here because it has both fields we need: password and totp code
return this.authTotpService.disableTotp(user, body.password, body.code);
}
private addTokensToResponse(
response: Response,
refreshToken?: string,
accessToken?: string,
) {
if (accessToken)
response.cookie("access_token", accessToken, { sameSite: "lax" });
if (refreshToken)
response.cookie("refresh_token", refreshToken, {
path: "/api/auth/token",
httpOnly: true,
sameSite: "strict",
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
});
return response;
}
}

View File

@@ -7,7 +7,12 @@ import { AuthTotpService } from "./authTotp.service";
import { JwtStrategy } from "./strategy/jwt.strategy";
@Module({
imports: [JwtModule.register({}), EmailModule],
imports: [
JwtModule.register({
global: true,
}),
EmailModule,
],
controllers: [AuthController],
providers: [AuthService, AuthTotpService, JwtStrategy],
exports: [AuthService],

View File

@@ -8,6 +8,7 @@ import { JwtService } from "@nestjs/jwt";
import { User } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import * as argon from "argon2";
import { Request, Response } from "express";
import * as moment from "moment";
import { ConfigService } from "src/config/config.service";
import { EmailService } from "src/email/email.service";
@@ -27,7 +28,7 @@ export class AuthService {
async signUp(dto: AuthRegisterDTO) {
const isFirstUser = (await this.prisma.user.count()) == 0;
const hash = await argon.hash(dto.password);
const hash = dto.password ? await argon.hash(dto.password) : null;
try {
const user = await this.prisma.user.create({
data: {
@@ -43,7 +44,7 @@ export class AuthService {
);
const accessToken = await this.createAccessToken(user, refreshTokenId);
return { accessToken, refreshToken };
return { accessToken, refreshToken, user };
} catch (e) {
if (e instanceof PrismaClientKnownRequestError) {
if (e.code == "P2002") {
@@ -69,9 +70,16 @@ export class AuthService {
if (!user || !(await argon.verify(user.password, dto.password)))
throw new UnauthorizedException("Wrong email or password");
return this.generateToken(user);
}
async generateToken(user: User, isOAuth = false) {
// TODO: Make all old loginTokens invalid when a new one is created
// Check if the user has TOTP enabled
if (user.totpVerified) {
if (
user.totpVerified &&
!(isOAuth && this.config.get("oauth.ignoreTotp"))
) {
const loginToken = await this.createLoginToken(user.id);
return { loginToken };
@@ -129,9 +137,11 @@ export class AuthService {
});
}
async updatePassword(user: User, oldPassword: string, newPassword: string) {
if (!(await argon.verify(user.password, oldPassword)))
throw new ForbiddenException("Invalid password");
async updatePassword(user: User, newPassword: string, oldPassword?: string) {
const isPasswordValid =
!user.password || !(await argon.verify(user.password, oldPassword));
if (!isPasswordValid) throw new ForbiddenException("Invalid password");
const hash = await argon.hash(newPassword);
@@ -210,4 +220,38 @@ export class AuthService {
return loginToken;
}
addTokensToResponse(
response: Response,
refreshToken?: string,
accessToken?: string,
) {
if (accessToken)
response.cookie("access_token", accessToken, { sameSite: "lax" });
if (refreshToken)
response.cookie("refresh_token", refreshToken, {
path: "/api/auth/token",
httpOnly: true,
sameSite: "strict",
maxAge: 1000 * 60 * 60 * 24 * 30 * 3,
});
}
/**
* Returns the user id if the user is logged in, null otherwise
*/
async getIdOfCurrentUser(request: Request): Promise<string | null> {
if (!request.cookies.access_token) return null;
try {
const payload = await this.jwtService.verifyAsync(
request.cookies.access_token,
{
secret: this.config.get("internal.jwtSecret"),
},
);
return payload.sub;
} catch {
return null;
}
}
}

View File

@@ -22,43 +22,29 @@ export class AuthTotpService {
) {}
async signInTotp(dto: AuthSignInTotpDTO) {
if (!dto.email && !dto.username)
throw new BadRequestException("Email or username is required");
const user = await this.prisma.user.findFirst({
where: {
OR: [{ email: dto.email }, { username: dto.username }],
},
});
if (!user || !(await argon.verify(user.password, dto.password)))
throw new UnauthorizedException("Wrong email or password");
const token = await this.prisma.loginToken.findFirst({
where: {
token: dto.loginToken,
},
include: {
user: true,
},
});
if (!token || token.userId != user.id || token.used)
if (!token || token.used)
throw new UnauthorizedException("Invalid login token");
if (token.expiresAt < new Date())
throw new UnauthorizedException("Login token expired", "token_expired");
// Check the TOTP code
const { totpSecret } = await this.prisma.user.findUnique({
where: { id: user.id },
select: { totpSecret: true },
});
const { totpSecret } = token.user;
if (!totpSecret) {
throw new BadRequestException("TOTP is not enabled");
}
const expected = authenticator.generate(totpSecret);
if (dto.totp !== expected) {
if (!authenticator.check(dto.totp, totpSecret)) {
throw new BadRequestException("Invalid code");
}
@@ -69,9 +55,9 @@ export class AuthTotpService {
});
const { refreshToken, refreshTokenId } =
await this.authService.createRefreshToken(user.id);
await this.authService.createRefreshToken(token.user.id);
const accessToken = await this.authService.createAccessToken(
user,
token.user,
refreshTokenId,
);

View File

@@ -1,7 +1,7 @@
import { IsString } from "class-validator";
import { AuthSignInDTO } from "./authSignIn.dto";
export class AuthSignInTotpDTO extends AuthSignInDTO {
export class AuthSignInTotpDTO {
@IsString()
totp: string;

View File

@@ -1,8 +1,9 @@
import { PickType } from "@nestjs/swagger";
import { IsString } from "class-validator";
import { IsOptional, IsString } from "class-validator";
import { UserDTO } from "src/user/dto/user.dto";
export class UpdatePasswordDTO extends PickType(UserDTO, ["password"]) {
@IsString()
oldPassword: string;
@IsOptional()
oldPassword?: string;
}