mirror of
https://github.com/apricote/Listory.git
synced 2026-01-13 21:21:02 +00:00
feat: implement long-lived sessions
This commit is contained in:
parent
d0705afca8
commit
44f7e26270
35 changed files with 739 additions and 190 deletions
26
src/auth/auth-session.entity.ts
Normal file
26
src/auth/auth-session.entity.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from "typeorm";
|
||||
import { User } from "../users/user.entity";
|
||||
|
||||
@Entity()
|
||||
export class AuthSession {
|
||||
@PrimaryGeneratedColumn("uuid")
|
||||
id: string;
|
||||
|
||||
@ManyToOne((type) => User, { eager: true })
|
||||
user: User;
|
||||
|
||||
@CreateDateColumn()
|
||||
createdAt: Date;
|
||||
|
||||
@Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" })
|
||||
lastUsedAt: Date;
|
||||
|
||||
@Column({ type: "timestamp", nullable: true })
|
||||
revokedAt: Date | null;
|
||||
}
|
||||
23
src/auth/auth-session.repository.ts
Normal file
23
src/auth/auth-session.repository.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// tslint:disable: max-classes-per-file
|
||||
import { EntityRepository, Repository, SelectQueryBuilder } from "typeorm";
|
||||
import { User } from "../users/user.entity";
|
||||
import { AuthSession } from "./auth-session.entity";
|
||||
|
||||
export class AuthSessionScopes extends SelectQueryBuilder<AuthSession> {
|
||||
/**
|
||||
* `byUser` scopes the query to AuthSessions created by the user.
|
||||
* @param currentUser
|
||||
*/
|
||||
byUser(currentUser: User): this {
|
||||
return this.andWhere(`session."userId" = :userID`, {
|
||||
userID: currentUser.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@EntityRepository(AuthSession)
|
||||
export class AuthSessionRepository extends Repository<AuthSession> {
|
||||
get scoped(): AuthSessionScopes {
|
||||
return new AuthSessionScopes(this.createQueryBuilder("session"));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,23 @@
|
|||
import { Controller, Get, Res, UseFilters, UseGuards } from "@nestjs/common";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Res,
|
||||
UseFilters,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { Response } from "express";
|
||||
import { User } from "../users/user.entity";
|
||||
import { AuthSession } from "./auth-session.entity";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { COOKIE_REFRESH_TOKEN } from "./constants";
|
||||
import { ReqUser } from "./decorators/req-user.decorator";
|
||||
import { RefreshAccessTokenResponseDto } from "./dto/refresh-access-token-response.dto";
|
||||
import {
|
||||
RefreshTokenAuthGuard,
|
||||
SpotifyAuthGuard,
|
||||
} from "./guards/auth-strategies.guard";
|
||||
import { SpotifyAuthFilter } from "./spotify.filter";
|
||||
|
||||
@Controller("api/v1/auth")
|
||||
|
|
@ -15,26 +28,33 @@ export class AuthController {
|
|||
) {}
|
||||
|
||||
@Get("spotify")
|
||||
@UseGuards(AuthGuard("spotify"))
|
||||
@UseGuards(SpotifyAuthGuard)
|
||||
spotifyRedirect() {
|
||||
// User is redirected by AuthGuard
|
||||
}
|
||||
|
||||
@Get("spotify/callback")
|
||||
@UseFilters(SpotifyAuthFilter)
|
||||
@UseGuards(AuthGuard("spotify"))
|
||||
@UseGuards(SpotifyAuthGuard)
|
||||
async spotifyCallback(@ReqUser() user: User, @Res() res: Response) {
|
||||
const { accessToken } = await this.authService.createToken(user);
|
||||
const { refreshToken } = await this.authService.createSession(user);
|
||||
|
||||
// Transmit accessToken to Frontend
|
||||
res.cookie("listory_access_token", accessToken, {
|
||||
maxAge: 24 * 60 * 60 * 1000, // 1 day
|
||||
|
||||
// Must be readable by SPA
|
||||
httpOnly: false,
|
||||
});
|
||||
// Refresh token should not be accessible to frontend to reduce risk
|
||||
// of XSS attacks.
|
||||
res.cookie(COOKIE_REFRESH_TOKEN, refreshToken, { httpOnly: true });
|
||||
|
||||
// Redirect User to SPA
|
||||
res.redirect("/login/success?source=spotify");
|
||||
}
|
||||
|
||||
@Post("token/refresh")
|
||||
@UseGuards(RefreshTokenAuthGuard)
|
||||
async refreshAccessToken(
|
||||
// With RefreshTokenAuthGuard the session is available instead of user
|
||||
@ReqUser() session: AuthSession
|
||||
): Promise<RefreshAccessTokenResponseDto> {
|
||||
const { accessToken } = await this.authService.createAccessToken(session);
|
||||
|
||||
return { accessToken };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import { Module } from "@nestjs/common";
|
||||
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtModule } from "@nestjs/jwt";
|
||||
import { PassportModule } from "@nestjs/passport";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { CookieParserMiddleware } from "../cookie-parser";
|
||||
import { UsersModule } from "../users/users.module";
|
||||
import { AuthSessionRepository } from "./auth-session.repository";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { JwtStrategy } from "./jwt.strategy";
|
||||
import { SpotifyStrategy } from "./spotify.strategy";
|
||||
import { AccessTokenStrategy } from "./strategies/access-token.strategy";
|
||||
import { RefreshTokenStrategy } from "./strategies/refresh-token.strategy";
|
||||
import { SpotifyStrategy } from "./strategies/spotify.strategy";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AuthSessionRepository]),
|
||||
PassportModule.register({ defaultStrategy: "jwt" }),
|
||||
JwtModule.registerAsync({
|
||||
useFactory: (config: ConfigService) => ({
|
||||
|
|
@ -23,8 +28,17 @@ import { SpotifyStrategy } from "./spotify.strategy";
|
|||
}),
|
||||
UsersModule,
|
||||
],
|
||||
providers: [AuthService, SpotifyStrategy, JwtStrategy],
|
||||
providers: [
|
||||
AuthService,
|
||||
SpotifyStrategy,
|
||||
AccessTokenStrategy,
|
||||
RefreshTokenStrategy,
|
||||
],
|
||||
exports: [PassportModule],
|
||||
controllers: [AuthController],
|
||||
})
|
||||
export class AuthModule {}
|
||||
export class AuthModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(CookieParserMiddleware).forRoutes("api/v1/auth");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,27 @@
|
|||
import { Injectable, ForbiddenException } from "@nestjs/common";
|
||||
import { ForbiddenException, Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { JwtService } from "@nestjs/jwt";
|
||||
import { User } from "../users/user.entity";
|
||||
import { UsersService } from "../users/users.service";
|
||||
import { AuthSession } from "./auth-session.entity";
|
||||
import { AuthSessionRepository } from "./auth-session.repository";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly userFilter: null | string;
|
||||
private readonly sessionExpirationTime: string;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly usersService: UsersService,
|
||||
private readonly jwtService: JwtService
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly authSessionRepository: AuthSessionRepository
|
||||
) {
|
||||
this.userFilter = this.config.get<string>("SPOTIFY_USER_FILTER");
|
||||
this.sessionExpirationTime = this.config.get<string>(
|
||||
"SESSION_EXPIRATION_TIME"
|
||||
);
|
||||
}
|
||||
|
||||
async spotifyLogin({
|
||||
|
|
@ -38,6 +46,67 @@ export class AuthService {
|
|||
return user;
|
||||
}
|
||||
|
||||
async createSession(
|
||||
user: User
|
||||
): Promise<{
|
||||
session: AuthSession;
|
||||
refreshToken: string;
|
||||
}> {
|
||||
const session = this.authSessionRepository.create();
|
||||
|
||||
session.user = user;
|
||||
await this.authSessionRepository.save(session);
|
||||
|
||||
const [{ refreshToken }] = await Promise.all([
|
||||
this.createRefreshToken(session),
|
||||
this.createAccessToken(session),
|
||||
]);
|
||||
|
||||
return { session, refreshToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* createRefreshToken should only be used while creating a new session.
|
||||
* @param session
|
||||
*/
|
||||
private async createRefreshToken(
|
||||
session: AuthSession
|
||||
): Promise<{ refreshToken }> {
|
||||
const payload = {
|
||||
sub: session.user.id,
|
||||
name: session.user.displayName,
|
||||
};
|
||||
|
||||
const token = await this.jwtService.signAsync(payload, {
|
||||
jwtid: session.id,
|
||||
// jwtService uses the shorter access token time as a default
|
||||
expiresIn: this.sessionExpirationTime,
|
||||
});
|
||||
|
||||
return { refreshToken: token };
|
||||
}
|
||||
|
||||
async createAccessToken(session: AuthSession): Promise<{ accessToken }> {
|
||||
if (session.revokedAt) {
|
||||
throw new ForbiddenException("SessionIsRevoked");
|
||||
}
|
||||
|
||||
const payload = {
|
||||
sub: session.user.id,
|
||||
name: session.user.displayName,
|
||||
picture: session.user.photo,
|
||||
};
|
||||
|
||||
const token = await this.jwtService.signAsync(payload);
|
||||
|
||||
return { accessToken: token };
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to createAccessToken
|
||||
* @deprecated
|
||||
* @param user
|
||||
*/
|
||||
async createToken(user: User): Promise<{ accessToken }> {
|
||||
const payload = {
|
||||
sub: user.id,
|
||||
|
|
@ -50,6 +119,10 @@ export class AuthService {
|
|||
return { accessToken: token };
|
||||
}
|
||||
|
||||
async findSession(id: string): Promise<AuthSession> {
|
||||
return this.authSessionRepository.findOne(id);
|
||||
}
|
||||
|
||||
async findUser(id: string): Promise<User> {
|
||||
return this.usersService.findById(id);
|
||||
}
|
||||
|
|
|
|||
1
src/auth/constants.ts
Normal file
1
src/auth/constants.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const COOKIE_REFRESH_TOKEN = "listory_refresh_token";
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import { applyDecorators, UseGuards } from "@nestjs/common";
|
||||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { ApiBearerAuth, ApiUnauthorizedResponse } from "@nestjs/swagger";
|
||||
import { AccessTokenAuthGuard } from "../guards/auth-strategies.guard";
|
||||
|
||||
export function Auth() {
|
||||
export function AuthAccessToken() {
|
||||
return applyDecorators(
|
||||
UseGuards(AuthGuard("jwt")),
|
||||
UseGuards(AccessTokenAuthGuard),
|
||||
ApiBearerAuth(),
|
||||
ApiUnauthorizedResponse({ description: 'Unauthorized"' })
|
||||
);
|
||||
3
src/auth/dto/refresh-access-token-response.dto.ts
Normal file
3
src/auth/dto/refresh-access-token-response.dto.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export class RefreshAccessTokenResponseDto {
|
||||
accessToken: string;
|
||||
}
|
||||
9
src/auth/guards/auth-strategies.guard.ts
Normal file
9
src/auth/guards/auth-strategies.guard.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { AuthGuard } from "@nestjs/passport";
|
||||
import { AuthStrategy } from "../strategies/strategies.enum";
|
||||
|
||||
// Internal
|
||||
export const AccessTokenAuthGuard = AuthGuard(AuthStrategy.AccessToken);
|
||||
export const RefreshTokenAuthGuard = AuthGuard(AuthStrategy.RefreshToken);
|
||||
|
||||
// Auth Provider
|
||||
export const SpotifyAuthGuard = AuthGuard(AuthStrategy.Spotify);
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
import { Injectable } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { Strategy, ExtractJwt } from "passport-jwt";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||
import { AuthService } from "../auth.service";
|
||||
import { AuthStrategy } from "./strategies.enum";
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
export class AccessTokenStrategy extends PassportStrategy(
|
||||
Strategy,
|
||||
AuthStrategy.AccessToken
|
||||
) {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
config: ConfigService
|
||||
|
|
@ -17,7 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
async validate(payload: { sub: string }) {
|
||||
return this.authService.findUser(payload.sub);
|
||||
}
|
||||
}
|
||||
48
src/auth/strategies/refresh-token.strategy.ts
Normal file
48
src/auth/strategies/refresh-token.strategy.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import {
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
ForbiddenException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { JwtFromRequestFunction, Strategy } from "passport-jwt";
|
||||
import { AuthService } from "../auth.service";
|
||||
import { COOKIE_REFRESH_TOKEN } from "../constants";
|
||||
import { AuthStrategy } from "./strategies.enum";
|
||||
import { AuthSession } from "../auth-session.entity";
|
||||
|
||||
const extractJwtFromCookie: JwtFromRequestFunction = (req) => {
|
||||
const token = req.cookies[COOKIE_REFRESH_TOKEN] || null;
|
||||
return token;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RefreshTokenStrategy extends PassportStrategy(
|
||||
Strategy,
|
||||
AuthStrategy.RefreshToken
|
||||
) {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
config: ConfigService
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: extractJwtFromCookie,
|
||||
ignoreExpiration: false,
|
||||
secretOrKey: config.get<string>("JWT_SECRET"),
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: { jti: string }): Promise<AuthSession> {
|
||||
const session = await this.authService.findSession(payload.jti);
|
||||
|
||||
if (!session) {
|
||||
throw new UnauthorizedException("SessionNotFound");
|
||||
}
|
||||
|
||||
if (session.revokedAt) {
|
||||
throw new ForbiddenException("SessionIsRevoked");
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,10 +2,14 @@ import { Injectable } from "@nestjs/common";
|
|||
import { ConfigService } from "@nestjs/config";
|
||||
import { PassportStrategy } from "@nestjs/passport";
|
||||
import { Strategy } from "passport-spotify";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthService } from "../auth.service";
|
||||
import { AuthStrategy } from "./strategies.enum";
|
||||
|
||||
@Injectable()
|
||||
export class SpotifyStrategy extends PassportStrategy(Strategy) {
|
||||
export class SpotifyStrategy extends PassportStrategy(
|
||||
Strategy,
|
||||
AuthStrategy.Spotify
|
||||
) {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
config: ConfigService
|
||||
8
src/auth/strategies/strategies.enum.ts
Normal file
8
src/auth/strategies/strategies.enum.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export enum AuthStrategy {
|
||||
// Internal
|
||||
AccessToken = "access_token",
|
||||
RefreshToken = "refresh_token",
|
||||
|
||||
// Auth Provider
|
||||
Spotify = "spotify",
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue