feat(api): user authentication

This commit is contained in:
Julian Tölle 2020-02-01 16:11:48 +01:00
parent f14eda16ac
commit f253a66f86
41 changed files with 657 additions and 338 deletions

View file

@ -1,17 +1,17 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { AuthenticationModule } from "./authentication/authentication.module";
import { AuthModule } from "./auth/auth.module";
import { DatabaseModule } from "./database/database.module";
import { ConnectionsModule } from "./connections/connections.module";
import { FrontendModule } from './frontend/frontend.module';
import { SourcesModule } from "./sources/sources.module";
import { UsersModule } from "./users/users.module";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
DatabaseModule,
AuthenticationModule,
ConnectionsModule,
FrontendModule
AuthModule,
UsersModule,
SourcesModule
]
})
export class AppModule {}

View file

@ -0,0 +1,40 @@
import { Controller, Get, Res, UseGuards } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { Response } from "express";
import { User } from "../users/user.entity";
import { ReqUser } from "./decorators/req-user.decorator";
import { AuthService } from "./auth.service";
import { ConfigService } from "@nestjs/config";
@Controller("api/v1/auth")
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly config: ConfigService
) {}
@Get("spotify")
@UseGuards(AuthGuard("spotify"))
spotifyRedirect() {
// User is redirected by AuthGuard
}
@Get("spotify/callback")
@UseGuards(AuthGuard("spotify"))
async spotifyCallback(@ReqUser() user: User, @Res() res: Response) {
const { accessToken } = await this.authService.createToken(user);
// Transmit accessToken to Frontend
res.cookie("listory_access_token", accessToken, {
// SPA will directly read cookie, save it to local storage and delete it
// 15 Minutes should be enough
maxAge: 15 * 60 * 1000,
// Must be readable by SPA
httpOnly: false
});
// Redirect User to SPA
res.redirect("/");
}
}

27
src/auth/auth.module.ts Normal file
View file

@ -0,0 +1,27 @@
import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { UsersModule } from "../users/users.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtStrategy } from "./jwt.strategy";
import { SpotifyStrategy } from "./spotify.strategy";
@Module({
imports: [
PassportModule.register({ defaultStrategy: "jwt" }),
JwtModule.registerAsync({
useFactory: (config: ConfigService) => ({
secret: config.get<string>("JWT_SECRET"),
signOptions: { expiresIn: config.get<string>("JWT_EXPIRATION_TIME") }
}),
inject: [ConfigService]
}),
UsersModule
],
providers: [AuthService, SpotifyStrategy, JwtStrategy],
exports: [PassportModule],
controllers: [AuthController]
})
export class AuthModule {}

View file

@ -1,15 +1,15 @@
import { Test, TestingModule } from "@nestjs/testing";
import { ConnectionsService } from "./connections.service";
import { AuthService } from "./auth.service";
describe("ConnectionsService", () => {
let service: ConnectionsService;
describe("AuthService", () => {
let service: AuthService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [ConnectionsService]
providers: [AuthService]
}).compile();
service = module.get<ConnectionsService>(ConnectionsService);
service = module.get<AuthService>(AuthService);
});
it("should be defined", () => {

47
src/auth/auth.service.ts Normal file
View file

@ -0,0 +1,47 @@
import { Injectable } from "@nestjs/common";
import { User } from "../users/user.entity";
import { UsersService } from "../users/users.service";
import { LoginDto } from "./dto/login.dto";
import { JwtService } from "@nestjs/jwt";
@Injectable()
export class AuthService {
constructor(
private readonly usersService: UsersService,
private readonly jwtService: JwtService
) {}
async spotifyLogin({
accessToken,
refreshToken,
profile
}: LoginDto): Promise<User> {
const user = await this.usersService.createOrUpdate({
displayName: profile.displayName,
photo: profile.photos.length > 0 ? profile.photos[0] : null,
spotify: {
id: profile.id,
accessToken,
refreshToken
}
});
return user;
}
async createToken(user: User): Promise<{ accessToken }> {
const payload = {
sub: user.id,
name: user.displayName,
picture: user.photo
};
const token = await this.jwtService.signAsync(payload);
return { accessToken: token };
}
async findUser(id: string): Promise<User> {
return this.usersService.findById(id);
}
}

View file

@ -0,0 +1,11 @@
import { applyDecorators, UseGuards } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
import { ApiBearerAuth, ApiUnauthorizedResponse } from "@nestjs/swagger";
export function Auth() {
return applyDecorators(
UseGuards(AuthGuard("jwt")),
ApiBearerAuth(),
ApiUnauthorizedResponse({ description: 'Unauthorized"' })
);
}

View file

@ -0,0 +1,3 @@
import { createParamDecorator } from "@nestjs/common";
export const ReqUser = createParamDecorator((data, req) => req.user);

View file

@ -0,0 +1,9 @@
export class LoginDto {
accessToken: string;
refreshToken: string;
profile: {
id: string;
displayName: string;
photos: string[];
};
}

23
src/auth/jwt.strategy.ts Normal file
View file

@ -0,0 +1,23 @@
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";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly config: ConfigService,
private readonly authService: AuthService
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: config.get<string>("JWT_SECRET")
});
}
async validate(payload: any) {
return this.authService.findUser(payload.sub);
}
}

View file

@ -0,0 +1,33 @@
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";
@Injectable()
export class SpotifyStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly config: ConfigService,
private readonly authService: AuthService
) {
super({
clientID: config.get<string>("SPOTIFY_CLIENT_ID"),
clientSecret: config.get<string>("SPOTIFY_CLIENT_SECRET"),
callbackURL: `${config.get<string>("BASE_DOMAIN") ||
"http://localhost:3000"}/api/v1/auth/spotify/callback`,
scope: [
"user-read-private",
"user-read-email",
"user-read-recently-played"
]
});
}
async validate(accessToken: string, refreshToken: string, profile: any) {
return await this.authService.spotifyLogin({
accessToken,
refreshToken,
profile
});
}
}

View file

@ -1,10 +0,0 @@
import { Module } from "@nestjs/common";
import { PassportModule } from "@nestjs/passport";
import { JwtStrategy } from "./jwt.strategy";
@Module({
imports: [PassportModule.register({ defaultStrategy: "jwt" })],
providers: [JwtStrategy],
exports: [PassportModule]
})
export class AuthenticationModule {}

View file

@ -1,28 +0,0 @@
import { Injectable } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { passportJwtSecret } from "jwks-rsa";
import { ExtractJwt, Strategy } from "passport-jwt";
import { ConfigService } from "@nestjs/config";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly config: ConfigService) {
super({
secretOrKeyProvider: passportJwtSecret({
cache: true,
rateLimit: true,
jwksRequestsPerMinute: 5,
jwksUri: `${config.get<string>("AUTH0_DOMAIN")}.well-known/jwks.json`
}),
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
audience: config.get<string>("AUTH0_AUDIENCE"),
issuer: config.get<string>("AUTH0_DOMAIN"),
algorithms: ["RS256"]
});
}
validate(payload: any) {
return payload;
}
}

View file

@ -1,3 +0,0 @@
export enum ConnectionType {
SPOTIFY = "spotify"
}

View file

@ -1,14 +0,0 @@
import { Entity, Column, PrimaryGeneratedColumn } from "typeorm";
import { ConnectionType } from "./connection-type.enum";
@Entity()
export class Connection {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column()
userID: string;
@Column()
type: ConnectionType;
}

View file

@ -1,18 +0,0 @@
import { Test, TestingModule } from "@nestjs/testing";
import { ConnectionsController } from "./connections.controller";
describe("Connections Controller", () => {
let controller: ConnectionsController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ConnectionsController]
}).compile();
controller = module.get<ConnectionsController>(ConnectionsController);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
});

View file

@ -1,11 +0,0 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Controller("api/v1/connections")
export class ConnectionsController {
@Get()
@UseGuards(AuthGuard("jwt"))
get() {
return { msg: "Success!" };
}
}

View file

@ -1,12 +0,0 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { ConnectionsController } from "./connections.controller";
import { ConnectionsRepository } from "./connections.repository";
import { ConnectionsService } from "./connections.service";
@Module({
imports: [TypeOrmModule.forFeature([ConnectionsRepository])],
controllers: [ConnectionsController],
providers: [ConnectionsService]
})
export class ConnectionsModule {}

View file

@ -1,5 +0,0 @@
import { EntityRepository, Repository } from "typeorm";
import { Connection } from "./connection.entity";
@EntityRepository(Connection)
export class ConnectionsRepository extends Repository<Connection> {}

View file

@ -1,7 +0,0 @@
import { Injectable } from "@nestjs/common";
import { ConnectionsRepository } from "./connections.repository";
@Injectable()
export class ConnectionsService {
constructor(private readonly connectionRepository: ConnectionsRepository) {}
}

View file

@ -1,4 +1,4 @@
import { ConfigModule, ConfigService } from "@nestjs/config";
import { ConfigService } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
import { join } from "path";
@ -17,9 +17,9 @@ export const DatabaseModule = TypeOrmModule.forRootAsync({
entities: [join(__dirname, "..", "**/*.entity.{ts,js}")],
// Migrations
migrationsRun: true,
migrations: [join(__dirname, "migrations", "*.{ts,js}")],
synchronize: false
// migrationsRun: true,
// migrations: [join(__dirname, "migrations", "*.{ts,js}")],
synchronize: true
}),
inject: [ConfigService]
});

View file

@ -1,7 +0,0 @@
import { Controller, Get } from "@nestjs/common";
@Controller("")
export class FrontendController {
@Get()
index() {}
}

View file

@ -1,7 +0,0 @@
import { Module } from '@nestjs/common';
import { FrontendController } from './frontend.controller';
@Module({
controllers: [FrontendController]
})
export class FrontendModule {}

View file

@ -1,14 +1,23 @@
import { NestFactory } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express";
import { join } from "path";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.useStaticAssets(join(__dirname, "frontend", "public"));
app.setBaseViewsDir(join(__dirname, "frontend", "views"));
app.setViewEngine("mustache");
// Setup API Docs
const options = new DocumentBuilder()
.setTitle("Listory")
.setDescription("Track and analyze your Spotify Listens")
.setVersion("1.0")
.addBearerAuth()
.addTag("user")
.addTag("listens")
.addTag("auth")
.build();
const document = SwaggerModule.createDocument(app, options);
SwaggerModule.setup("api/docs", app, document);
await app.listen(3000);
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { SpotifyModule } from './spotify/spotify.module';
@Module({
imports: [SpotifyModule]
})
export class SourcesModule {}

View file

@ -0,0 +1,12 @@
import { Column } from "typeorm";
export class SpotifyConnection {
@Column()
id: string;
@Column()
accessToken: string;
@Column()
refreshToken: string;
}

View file

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { SpotifyService } from './spotify.service';
import { SpotifyApiModule } from './spotify-api/spotify-api.module';
@Module({
providers: [SpotifyService],
imports: [SpotifyApiModule]
})
export class SpotifyModule {}

View file

@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { SpotifyService } from './spotify.service';
describe('SpotifyService', () => {
let service: SpotifyService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [SpotifyService],
}).compile();
service = module.get<SpotifyService>(SpotifyService);
});
it('should be defined', () => {
expect(service).toBeDefined();
});
});

View file

@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
import {Interval} from "@nestjs/cron"
@Injectable()
export class SpotifyService {
@Interval
}

View file

@ -0,0 +1,10 @@
export class CreateOrUpdateDto {
displayName: string;
photo?: string;
spotify: {
id: string;
accessToken: string;
refreshToken: string;
};
}

17
src/users/user.entity.ts Normal file
View file

@ -0,0 +1,17 @@
import { Entity, Column, PrimaryGeneratedColumn, OneToOne } from "typeorm";
import { SpotifyConnection } from "../sources/spotify/spotify-connection.entity";
@Entity()
export class User {
@PrimaryGeneratedColumn("uuid")
id: string;
@Column()
displayName: string;
@Column({ nullable: true })
photo?: string;
@Column(type => SpotifyConnection)
spotify: SpotifyConnection;
}

View file

@ -0,0 +1,5 @@
import { EntityRepository, Repository } from "typeorm";
import { User } from "./user.entity";
@EntityRepository(User)
export class UserRepository extends Repository<User> {}

View file

@ -1,15 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing';
import { FrontendController } from './frontend.controller';
import { UsersController } from './users.controller';
describe('Frontend Controller', () => {
let controller: FrontendController;
describe('Users Controller', () => {
let controller: UsersController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [FrontendController],
controllers: [UsersController],
}).compile();
controller = module.get<FrontendController>(FrontendController);
controller = module.get<UsersController>(UsersController);
});
it('should be defined', () => {

View file

@ -0,0 +1,17 @@
import { Controller, Get } from "@nestjs/common";
import { Auth } from "../auth/decorators/auth.decorator";
import { ReqUser } from "../auth/decorators/req-user.decorator";
import { User } from "./user.entity";
@Controller("api/v1/users")
export class UsersController {
@Get("me")
@Auth()
getMe(@ReqUser() user: User): Omit<User, "spotify"> {
return {
id: user.id,
displayName: user.displayName,
photo: user.photo
};
}
}

13
src/users/users.module.ts Normal file
View file

@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { UserRepository } from "./user.repository";
import { UsersService } from "./users.service";
import { UsersController } from './users.controller';
@Module({
imports: [TypeOrmModule.forFeature([UserRepository])],
providers: [UsersService],
exports: [UsersService],
controllers: [UsersController]
})
export class UsersModule {}

View file

@ -0,0 +1,18 @@
import { Test, TestingModule } from "@nestjs/testing";
import { UsersService } from "./users.service";
describe("UsersService", () => {
let service: UsersService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [UsersService]
}).compile();
service = module.get<UsersService>(UsersService);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
});

View file

@ -0,0 +1,42 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { CreateOrUpdateDto } from "./dto/create-or-update.dto";
import { User } from "./user.entity";
import { UserRepository } from "./user.repository";
@Injectable()
export class UsersService {
constructor(private readonly userRepository: UserRepository) {}
async findById(id: string): Promise<User> {
const user = await this.userRepository.findOne(id);
if (!user) {
throw new NotFoundException("UserNotFound");
}
return user;
}
async createOrUpdate(data: CreateOrUpdateDto): Promise<User> {
let user = await this.userRepository.findOne({
where: { spotify: { id: data.spotify.id } }
});
if (!user) {
user = this.userRepository.create({
spotify: {
id: data.spotify.id
}
});
}
user.spotify.accessToken = data.spotify.accessToken;
user.spotify.refreshToken = data.spotify.refreshToken;
user.displayName = data.displayName;
user.photo = data.photo;
await this.userRepository.save(user);
return user;
}
}