This commit is contained in:
Bastian Wagner
2024-09-12 21:33:11 +02:00
parent 6abfdcb632
commit c00aad559d
36 changed files with 1118 additions and 397 deletions

View File

@@ -1,22 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AppController } from './app.controller';
import { AppService } from './app.service';
describe('AppController', () => {
let appController: AppController;
beforeEach(async () => {
const app: TestingModule = await Test.createTestingModule({
controllers: [AppController],
providers: [AppService],
}).compile();
appController = app.get<AppController>(AppController);
});
describe('root', () => {
it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!');
});
});
});

View File

@@ -1,5 +1,6 @@
import { Controller, Get } from '@nestjs/common';
import { Body, Controller, Get, Post } from '@nestjs/common';
import { AppService } from './app.service';
import { LoginDTO } from './model/dto/login.dto';
@Controller()
export class AppController {
@@ -9,4 +10,9 @@ export class AppController {
getHello(): any {
return this.appService.getHello();
}
@Post()
login(@Body() createUserDto: LoginDTO) {
return { success: createUserDto };
}
}

View File

@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './shared/database/database.module';
import { AuthModule } from './modules/auth/auth.module';
@Module({
imports: [
@@ -10,28 +11,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
envFilePath: ['.env'],
isGlobal: true,
}),
TypeOrmModule.forRootAsync({
useFactory: () => ({
type: 'mysql',
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT) || 3306,
username: process.env.MYSQL_USER,
password: process.env.MYSQL_PASSWORD,
database: process.env.MYSQL_DATABASE,
synchronize: true,
autoLoadEntities: true,
retryAttempts: 5,
retryDelay: 10000,
logging: ['error'],
logger: 'file',
}),
}),
DatabaseModule,
AuthModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {
constructor(private config: ConfigService) {
console.log(this.config.get('MYSQL_USER'))
}
}
export class AppModule {}

View File

@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
constructor() {}
getHello(): any {
return { success: true, date: new Date() };
}

View File

@@ -1,8 +1,11 @@
import { NestFactory } from '@nestjs/core';
import { NestFactory, Reflector } from '@nestjs/core';
import { AppModule } from './app.module';
import { ClassSerializerInterceptor, ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)));
app.useGlobalPipes(new ValidationPipe());
await app.listen(4000);
}
bootstrap();

View File

@@ -0,0 +1,6 @@
import { IsNotEmpty } from 'class-validator';
export class AuthCodeDto {
@IsNotEmpty()
code: string;
}

View File

@@ -0,0 +1,9 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
export class CreateUserDto {
@IsEmail()
username: string;
@IsNotEmpty()
externalId: string;
}

View File

@@ -0,0 +1,2 @@
export * from './login.dto';
export * from './auth-code.dto';

View File

@@ -0,0 +1,9 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
export class LoginDTO {
@IsEmail()
username: string;
@IsNotEmpty()
password: string;
}

View File

@@ -0,0 +1,2 @@
export * from './sso.user.entity';
export * from './user.entity';

View File

@@ -0,0 +1,18 @@
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
import { User } from './user.entity';
@Entity()
export class SSOUser {
@PrimaryColumn({ type: 'uuid', unique: true })
externalId: string;
@OneToOne(() => User, (user) => user.external)
@JoinColumn()
user: User;
@Column({ nullable: true, type: 'text' })
accessToken: string;
@Column({ nullable: true, type: 'text' })
refreshToken: string;
}

View File

@@ -0,0 +1,44 @@
import { Exclude } from 'class-transformer';
import {
Column,
CreateDateColumn,
Entity,
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { IUser } from '../interface';
import { SSOUser } from './sso.user.entity';
import { IsEmail } from 'class-validator';
@Entity()
export class User implements IUser {
@PrimaryGeneratedColumn('uuid')
id: string;
@IsEmail()
@Column({ unique: true })
username: string;
@Column({ name: 'first_name', default: '' })
firstName: string;
@Column({ name: 'last_name', default: '' })
lastName: string;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@Column({ default: null })
lastLogin: Date;
@Exclude()
@OneToOne(() => SSOUser, (sso) => sso.user, { eager: true, cascade: true })
external: SSOUser;
@Exclude()
@Column({ default: true })
isActive: boolean;
accessToken?: string;
refreshToken?: string;
}

View File

@@ -0,0 +1,10 @@
export interface IExternalAccessPayload {
username: string;
id: string;
firstName: string;
lastName: string;
iss: string;
aud: string;
iat: number;
exp: number;
}

View File

@@ -0,0 +1,3 @@
export * from './user.interface';
export * from './external-access-token.payload.interface';
export * from './payload.interface';

View File

@@ -0,0 +1,5 @@
export interface IPayload {
id: string;
username: string;
type: 'access' | 'refresh';
}

View File

@@ -0,0 +1,10 @@
export interface IUser {
id: string;
username: string;
firstName: string;
lastName: string;
accessToken?: string;
refreshToken?: string;
}

View File

@@ -0,0 +1,2 @@
export * from './user.repository';
export * from './ssouser.repository';

View File

@@ -0,0 +1,18 @@
import { Injectable } from '@nestjs/common';
import { Repository, DataSource } from 'typeorm';
import { SSOUser } from '../entitites';
@Injectable()
export class SsoUserRepository extends Repository<SSOUser> {
constructor(dataSource: DataSource) {
super(SSOUser, dataSource.createEntityManager());
}
findOneByUserId(id: string): Promise<SSOUser> {
return this.findOne({ where: { user: { id: id } } });
}
findByExternalId(id: string): Promise<SSOUser> {
return this.findOne({ where: { externalId: id } });
}
}

View File

@@ -0,0 +1,61 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { Repository, DataSource } from 'typeorm';
import { User } from '../entitites';
import { CreateUserDto } from '../dto/create-user.dto';
import { SsoUserRepository } from './ssouser.repository';
@Injectable()
export class UserRepository extends Repository<User> {
constructor(
dataSource: DataSource,
private ssoRepo: SsoUserRepository,
) {
super(User, dataSource.createEntityManager());
}
findByUsername(username: string): Promise<User> {
return this.findOne({ where: { username }, relations: ['external'] });
}
findById(id: string): Promise<User> {
return this.findOne({ where: { id }, relations: ['external'] });
}
async createUser(createUserDto: CreateUserDto): Promise<User> {
if (
!(await this.checkIfCanInserted(
createUserDto.username,
createUserDto.externalId,
))
) {
throw new HttpException('user_exists', HttpStatus.UNPROCESSABLE_ENTITY);
}
try {
const created = this.create(createUserDto);
const sso = this.ssoRepo.create({
user: created,
externalId: createUserDto.externalId,
});
created.external = sso;
const user = await this.save(created);
sso.user = user;
this.ssoRepo.save(sso);
return user;
} catch {
throw new HttpException(
'not_successfull',
HttpStatus.UNPROCESSABLE_ENTITY,
);
}
}
private async checkIfCanInserted(
username: string,
externalId: string,
): Promise<boolean> {
const user = await this.findOneBy({ username });
const sso = await this.ssoRepo.findByExternalId(externalId);
return user == null && sso == null;
}
}

View File

@@ -0,0 +1,26 @@
import {
Body,
Controller,
HttpException,
HttpStatus,
Post,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthCodeDto } from 'src/model/dto';
import { User } from 'src/model/entitites';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('auth-code')
async registerOrLoginWithAuthCode(
@Body() authDto: AuthCodeDto,
): Promise<User> {
const user = await this.authService.registerOrLoginWithAuthCode(authDto);
if (user == null) {
throw new HttpException('forbidden', HttpStatus.FORBIDDEN);
}
return user;
}
}

View File

@@ -0,0 +1,26 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { DatabaseModule } from 'src/shared/database/database.module';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { HttpModule } from '@nestjs/axios';
import { JwtModule } from '@nestjs/jwt';
@Module({
controllers: [AuthController],
providers: [AuthService],
imports: [
DatabaseModule,
ConfigModule,
HttpModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: async (config: ConfigService) => ({
secret: config.get('JWT_SECRET'),
signOptions: { expiresIn: config.get('JWT_EXPIRES_IN') },
}),
}),
],
})
export class AuthModule {}

View File

@@ -0,0 +1,106 @@
import { Injectable } from '@nestjs/common';
import { AuthCodeDto } from 'src/model/dto';
import { CreateUserDto } from 'src/model/dto/create-user.dto';
import { UserRepository } from 'src/model/repositories';
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { IExternalAccessPayload, IPayload } from 'src/model/interface';
import { User } from 'src/model/entitites';
@Injectable()
export class AuthService {
constructor(
private userRepo: UserRepository,
private readonly http: HttpService,
private configService: ConfigService,
private jwt: JwtService,
) {}
register(register: CreateUserDto) {
return this.userRepo.createUser(register);
}
async registerOrLoginWithAuthCode(auth: AuthCodeDto): Promise<User> {
console.log(auth);
const body = this.createAuthCodeFormData(auth.code, 'authorization_code');
const url = this.configService.get('SSO_TOKEN_URL');
return new Promise<User>((resolve) => {
this.http.post(url, body).subscribe({
next: async (response) => {
const user = await this.saveExternalTokens(response.data as any);
this.generateTokens(user);
resolve(user);
},
error: () => {
resolve(null);
},
});
});
}
private async saveExternalTokens({
access_token,
refresh_token,
}: {
access_token: string;
refresh_token: string;
}): Promise<User> {
console.log(access_token, refresh_token);
const payload: IExternalAccessPayload = this.jwt.decode(access_token);
return new Promise<User>(async (resolve) => {
let user = await this.userRepo.findByUsername(payload.username);
if (!user) {
user = await this.userRepo.createUser({
username: payload.username,
externalId: payload.id,
});
}
user.firstName = payload.firstName;
user.lastName = payload.lastName;
user.external.accessToken = access_token;
user.external.refreshToken = refresh_token;
await this.userRepo.save(user);
resolve(user);
});
}
private generateTokens(user: User) {
const payload: IPayload = {
username: user.username,
id: user.id,
type: 'access',
};
const token = this.jwt.sign(payload);
user.accessToken = token;
const rPay: IPayload = {
username: user.username,
id: user.id,
type: 'refresh',
};
user.refreshToken = this.jwt.sign(rPay);
}
private createAuthCodeFormData(
code: string,
grant_type = 'authorization_code',
): FormData {
const bodyFormData = new FormData();
bodyFormData.append('client_id', this.configService.get('SSO_CLIENT_ID'));
bodyFormData.append(
'client_secret',
this.configService.get('SSO_CLIENT_SECRET'),
);
bodyFormData.append('code', code);
bodyFormData.append('grant_type', grant_type);
bodyFormData.append(
'redirect_uri',
this.configService.get('SSO_REDIRECT_URI'),
);
return bodyFormData;
}
}

View File

@@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SSOUser, User } from 'src/model/entitites';
import { SsoUserRepository, UserRepository } from 'src/model/repositories';
const ENTITIES = [User, SSOUser];
const REPOSITORIES = [UserRepository, SsoUserRepository];
@Module({
imports: [
TypeOrmModule.forRootAsync({
useFactory: () => ({
type: 'mysql',
host: process.env.DATABASE_HOST,
port: parseInt(process.env.DATABASE_PORT) || 3306,
username: process.env.MYSQL_USER,
password: process.env.MYSQL_PASSWORD,
database: process.env.MYSQL_DATABASE,
synchronize: true,
autoLoadEntities: true,
retryAttempts: 5,
retryDelay: 10000,
logging: ['error'],
logger: 'file',
entities: [...ENTITIES],
}),
}),
],
providers: [...REPOSITORIES],
exports: [TypeOrmModule, ...REPOSITORIES],
})
export class DatabaseModule {}