feat: implement long-lived sessions

This commit is contained in:
Julian Tölle 2020-09-05 23:35:53 +02:00
parent d0705afca8
commit 44f7e26270
35 changed files with 739 additions and 190 deletions

View 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;
}

View 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"));
}
}

View file

@ -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 };
}
}

View file

@ -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");
}
}

View file

@ -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
View file

@ -0,0 +1 @@
export const COOKIE_REFRESH_TOKEN = "listory_refresh_token";

View file

@ -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"' })
);

View file

@ -0,0 +1,3 @@
export class RefreshAccessTokenResponseDto {
accessToken: string;
}

View 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);

View file

@ -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);
}
}

View 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;
}
}

View file

@ -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

View file

@ -0,0 +1,8 @@
export enum AuthStrategy {
// Internal
AccessToken = "access_token",
RefreshToken = "refresh_token",
// Auth Provider
Spotify = "spotify",
}