diff --git a/violet-server/server/src/app.module.ts b/violet-server/server/src/app.module.ts index dab15caf8..29c1761fd 100644 --- a/violet-server/server/src/app.module.ts +++ b/violet-server/server/src/app.module.ts @@ -42,8 +42,8 @@ export const envValidationSchema = Joi.object({ @Module({ imports: [ ThrottlerModule.forRoot([{ - ttl: 1000, // 1초 - limit: 1, // 1초에 1번만 요청 가능 + ttl: 5000, // 5초 + limit: 5, // 5초에 5번까지 요청 가능 }]), ConfigModule.forRoot({ diff --git a/violet-server/server/src/auth/auth.controller.ts b/violet-server/server/src/auth/auth.controller.ts index 21b1b3c85..f24c4be59 100644 --- a/violet-server/server/src/auth/auth.controller.ts +++ b/violet-server/server/src/auth/auth.controller.ts @@ -17,7 +17,7 @@ import { User } from 'src/user/entity/user.entity'; import { AuthService } from './auth.service'; import { AccessTokenGuard } from './guards/access-token.guard'; import { Tokens } from './jwt/jwt.token'; -import { Request, Response } from 'express'; +import { Request, response, Response } from 'express'; import { ResLoginUser } from './dtos/res-login-user.dto'; import { HmacAuthGuard } from './guards/hmac.guard'; import { DiscordAuthGuard } from './guards/discord.guard'; @@ -29,21 +29,33 @@ export class AuthController { constructor( private readonly configService: ConfigService, private readonly authService: AuthService, - ) {} + ) { } @Post() @UseGuards(HmacAuthGuard) @ApiOperation({ summary: 'Login' }) @ApiCreatedResponse({ description: 'jwt token', type: Tokens }) - @Redirect('violet://login') + // @Redirect('violet://login') async logIn( @Body() dto: UserRegisterDTO, @Res({ passthrough: true }) res: Response, ): Promise { + const accessExpires = new Date( + Date.now() + Number(this.configService.get('ACCESS_EXPIRES')) * 1000, + ); + const refreshExpires = new Date( + Date.now() + Number(this.configService.get('REFRESH_EXPIRES')) * 1000, + ); const { tokens } = await this.authService.verifyUserAndSignJWT(dto); - res.cookie('jwt-access', tokens.accessToken, { httpOnly: true }); - res.cookie('jwt-refresh', tokens.refreshToken, { httpOnly: true }); + res.cookie('jwt-access', tokens.accessToken, { + expires: accessExpires, + httpOnly: true, + }); + res.cookie('jwt-refresh', tokens.refreshToken, { + expires: refreshExpires, + httpOnly: true, + }); return tokens; } @@ -55,33 +67,24 @@ export class AuthController { @Res({ passthrough: true }) response: Response, ): Promise { const accessExpires = new Date( - Date.now() + Number(this.configService.get('ACCESS_EXPIRES')), - ); - const refreshExpires = new Date( - Date.now() + Number(this.configService.get('REFRESH_EXPIRES')), + Date.now() + Number(this.configService.get('ACCESS_EXPIRES')) * 1000, ); + const refreshToken = req.cookies['jwt-refresh']; const resRefreshData = await this.authService.refreshTokens( - req.cookies['jwt-refresh'], + refreshToken, ); + const expires = this.authService.getTokenExpires(refreshToken); response.cookie('jwt-access', resRefreshData.tokens.accessToken, { expires: accessExpires, httpOnly: true, }); + // TODO: 이거 안보내줘도 되지 않나? response.cookie('jwt-refresh', resRefreshData.tokens.refreshToken, { - expires: refreshExpires, + expires: new Date(expires * 1000), httpOnly: true, }); - response.cookie('refresh-expires', refreshExpires, { - httpOnly: false, - expires: refreshExpires, - }); - response.cookie('access-expires', refreshExpires, { - expires: accessExpires, - httpOnly: false, - }); - return resRefreshData; } diff --git a/violet-server/server/src/auth/auth.service.ts b/violet-server/server/src/auth/auth.service.ts index 116ebc7c1..0610441d8 100644 --- a/violet-server/server/src/auth/auth.service.ts +++ b/violet-server/server/src/auth/auth.service.ts @@ -19,7 +19,7 @@ export class AuthService { private readonly userRepository: UserRepository, private readonly jwtService: JwtService, private readonly configService: ConfigService, - ) {} + ) { } async verifyUserAndSignJWT(dto: UserRegisterDTO): Promise { const user = await this.userRepository.findOneBy({ @@ -45,17 +45,19 @@ export class AuthService { }; } - async createJWT(userAppId: string): Promise { - const accessExpires = Number( - new Date( - Date.now() + Number(this.configService.get('ACCESS_EXPIRES')), - ), - ); - const refreshExpires = Number( - new Date( - Date.now() + Number(this.configService.get('REFRESH_EXPIRES')), - ), - ); + async createJWT(userAppId: string, customExpires?: number): Promise { + const refreshPayload = { + userAppId, + } as any; + const refreshOptions = { + secret: this.configService.get('REFRESH_TOKEN_SECRET_KEY'), + expiresIn: Number(this.configService.get('REFRESH_EXPIRES')), + }; + if (customExpires) { + refreshPayload.exp = customExpires; + delete refreshOptions.expiresIn; + } + const [accessToken, refreshToken] = await Promise.all([ this.jwtService.signAsync( { @@ -63,17 +65,12 @@ export class AuthService { }, { secret: this.configService.get('ACCESS_TOKEN_SECRET_KEY'), - expiresIn: accessExpires, + expiresIn: Number(this.configService.get('ACCESS_EXPIRES')), }, ), this.jwtService.signAsync( - { - userAppId, - }, - { - secret: this.configService.get('REFRESH_TOKEN_SECRET_KEY'), - expiresIn: refreshExpires, - }, + refreshPayload, + refreshOptions, ), ]); @@ -84,6 +81,11 @@ export class AuthService { await this.userRepository.update({ userAppId }, { refreshToken }); } + getTokenExpires(token: string): number { + const decoded = this.jwtService.decode(token); + return Number(decoded.exp); + } + async refreshTokens(refreshToken: string): Promise { const user = await this.userRepository.findOneBy({ refreshToken: refreshToken, @@ -93,7 +95,8 @@ export class AuthService { throw new HttpException('Invalid Token', 401); } - const tokens = await this.createJWT(user.userAppId); + const expires = this.getTokenExpires(refreshToken); + const tokens = await this.createJWT(user.userAppId, expires); await this.updateRefreshToken(user.userAppId, tokens.refreshToken); return { tokens, user }; diff --git a/violet-server/server/src/comment/comment.controller.ts b/violet-server/server/src/comment/comment.controller.ts index f970b477a..f5b33ecf9 100644 --- a/violet-server/server/src/comment/comment.controller.ts +++ b/violet-server/server/src/comment/comment.controller.ts @@ -20,6 +20,7 @@ import { AccessTokenGuard } from 'src/auth/guards/access-token.guard'; import { CommentGetDto, CommentGetResponseDto } from './dtos/comment-get.dto'; import { CommonResponseDto } from 'src/common/dtos/common.dto'; import { CommentOwnerGuard } from './guards/comment-owner.guard'; +import { Throttle } from '@nestjs/throttler'; @ApiTags('comment') @Controller('comment') @@ -38,6 +39,7 @@ export class CommentController { return await this.commentService.getComment(dto); } + @Throttle({ default: { limit: 2, ttl: 60000 } }) @Post('/') @UsePipes(new ValidationPipe({ transform: true })) @ApiOperation({ summary: 'Post Comment' }) diff --git a/violet-server/server/src/common/filters/empty-body.filter.ts b/violet-server/server/src/common/filters/empty-body.filter.ts new file mode 100644 index 000000000..44fd99d81 --- /dev/null +++ b/violet-server/server/src/common/filters/empty-body.filter.ts @@ -0,0 +1,20 @@ +import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common'; +import { Response } from 'express'; + +@Catch(HttpException) +export class EmptyBodyHttpExceptionFilter implements ExceptionFilter { + catch(exception: HttpException, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const res = ctx.getResponse(); + const status = exception.getStatus(); + + if (status >= 400 && status < 500) { + res.status(status).end(); // body completely empty + } else { + res.status(status).json({ + statusCode: status, + message: exception.message, + }); + } + } +} \ No newline at end of file diff --git a/violet-server/server/src/discord/discord.service.ts b/violet-server/server/src/discord/discord.service.ts index 899ade041..120e81f62 100644 --- a/violet-server/server/src/discord/discord.service.ts +++ b/violet-server/server/src/discord/discord.service.ts @@ -26,7 +26,7 @@ export class DiscordService { fields: [ { name: 'Author', - value: username, + value: username.slice(0, 8), } ], timestamp: new Date().toISOString(), diff --git a/violet-server/server/src/main.ts b/violet-server/server/src/main.ts index e35989014..87313a1a8 100644 --- a/violet-server/server/src/main.ts +++ b/violet-server/server/src/main.ts @@ -4,6 +4,7 @@ import { setupSwagger } from './common/utils/swagger'; import { logger } from './common/utils/logger'; import * as cookies from 'cookie-parser'; import { ValidationPipe } from '@nestjs/common'; +import { EmptyBodyHttpExceptionFilter } from './common/filters/empty-body.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule, { logger: logger }); @@ -18,7 +19,13 @@ async function bootstrap() { credentials: true, // 쿠키와 인증 헤더를 포함한 요청 허용 }); - setupSwagger(app); + if (process.env.NODE_ENV === 'prod') { + app.useGlobalFilters(new EmptyBodyHttpExceptionFilter()); + } + + if (process.env.NODE_ENV === 'dev') { + setupSwagger(app); + } await app.listen(3000); } diff --git a/violet-server/server/src/user/entity/user.entity.ts b/violet-server/server/src/user/entity/user.entity.ts index ff38050dd..5b196b795 100644 --- a/violet-server/server/src/user/entity/user.entity.ts +++ b/violet-server/server/src/user/entity/user.entity.ts @@ -34,6 +34,7 @@ export class User extends CoreEntity { @ApiProperty({ description: 'Discord Id', + required: false, }) @Column({ nullable: true }) @Index() @@ -41,12 +42,14 @@ export class User extends CoreEntity { @ApiProperty({ description: 'Avatar', + required: false, }) @Column({ nullable: true }) avatar?: string; @ApiProperty({ description: 'Nickname', + required: false, }) @Column({ unique: true, nullable: true }) nickname?: string; diff --git a/violet/lib/api/api.swagger.dart b/violet/lib/api/api.swagger.dart index a0afeea42..2e22635f3 100644 --- a/violet/lib/api/api.swagger.dart +++ b/violet/lib/api/api.swagger.dart @@ -534,9 +534,9 @@ class User { required this.updatedAt, required this.userAppId, required this.role, - required this.discordId, - required this.avatar, - required this.nickname, + this.discordId, + this.avatar, + this.nickname, }); factory User.fromJson(Map json) => _$UserFromJson(json); @@ -562,11 +562,11 @@ class User { userRoleFromJson(value, enums.UserRole.user); @JsonKey(name: 'discordId') - final String discordId; + final String? discordId; @JsonKey(name: 'avatar') - final String avatar; + final String? avatar; @JsonKey(name: 'nickname') - final String nickname; + final String? nickname; static const fromJsonFactory = _$UserFromJson; @override @@ -639,9 +639,9 @@ extension $UserExtension on User { Wrapped? updatedAt, Wrapped? userAppId, Wrapped? role, - Wrapped? discordId, - Wrapped? avatar, - Wrapped? nickname}) { + Wrapped? discordId, + Wrapped? avatar, + Wrapped? nickname}) { return User( id: (id != null ? id.value : this.id), createdAt: (createdAt != null ? createdAt.value : this.createdAt), diff --git a/violet/lib/api/api.swagger.g.dart b/violet/lib/api/api.swagger.g.dart index acb02f399..ee354d452 100644 --- a/violet/lib/api/api.swagger.g.dart +++ b/violet/lib/api/api.swagger.g.dart @@ -62,9 +62,9 @@ User _$UserFromJson(Map json) => User( updatedAt: DateTime.parse(json['updatedAt'] as String), userAppId: json['userAppId'] as String, role: User.userRoleRoleFromJson(json['role']), - discordId: json['discordId'] as String, - avatar: json['avatar'] as String, - nickname: json['nickname'] as String, + discordId: json['discordId'] as String?, + avatar: json['avatar'] as String?, + nickname: json['nickname'] as String?, ); Map _$UserToJson(User instance) => { diff --git a/violet/lib/pages/lab/lab/global_comments.dart b/violet/lib/pages/lab/lab/global_comments.dart index 08a9da2c3..450db2345 100644 --- a/violet/lib/pages/lab/lab/global_comments.dart +++ b/violet/lib/pages/lab/lab/global_comments.dart @@ -5,12 +5,12 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:material_design_icons_flutter/material_design_icons_flutter.dart'; import 'package:mdi/mdi.dart'; +import 'package:violet/api/api.swagger.dart'; import 'package:violet/locale/locale.dart'; import 'package:violet/other/dialogs.dart'; import 'package:violet/pages/lab/lab/recent_user_record.dart'; import 'package:violet/pages/segment/card_panel.dart'; import 'package:violet/pages/segment/platform_navigator.dart'; -import 'package:violet/server/community/anon.dart'; import 'package:violet/server/violet_v2.dart'; import 'package:violet/settings/settings.dart'; @@ -163,12 +163,14 @@ class _LabGlobalCommentsState extends State { Translations.instance!.trans('comment')); return; } - if (!modReply) { - await VioletCommunityAnonymous.postArtistComment( - null, 'global_general', text.text); - } else { - await VioletCommunityAnonymous.postArtistComment( - replyParent, 'global_general', text.text); + await VioletServerV2.postComment( + CommentPostDto( + where: 'general', + body: text.text, + parent: modReply ? replyParent : null, + ), + ); + if (modReply) { replyParent = null; modReply = false; } diff --git a/violet/lib/pages/splash/splash_page.dart b/violet/lib/pages/splash/splash_page.dart index 5e25a1f60..e1e8e9b93 100644 --- a/violet/lib/pages/splash/splash_page.dart +++ b/violet/lib/pages/splash/splash_page.dart @@ -133,6 +133,8 @@ class _SplashPageState extends State { await IsolateDownloader.getInstance(); _changeMessage('init api...'); VioletServerV2.init(); + _changeMessage('init session manager...'); + await SessionManager.refresh(); // this may be slow down to loading _changeMessage('check network...'); diff --git a/violet/lib/server/violet_v2.dart b/violet/lib/server/violet_v2.dart index 5983d122f..235ca226e 100644 --- a/violet/lib/server/violet_v2.dart +++ b/violet/lib/server/violet_v2.dart @@ -4,9 +4,12 @@ import 'dart:async'; import 'package:chopper/chopper.dart'; +import 'package:jwt_decoder/jwt_decoder.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:violet/api/api.swagger.dart'; +import 'package:violet/log/log.dart'; import 'package:violet/server/wsalt.dart'; +import 'package:violet/settings/settings.dart'; class VioletServerV2 { static const protocol = 'https'; @@ -14,6 +17,7 @@ class VioletServerV2 { static const api = '$protocol://$host'; static late final Api instance; + static late final Api sessionInstance; static void init() { instance = Api.create( @@ -22,6 +26,13 @@ class VioletServerV2 { ); } + static void initSession() { + sessionInstance = Api.create( + baseUrl: Uri.parse(api), + interceptors: [HmacInterceptor(), SessionInterceptor()], + ); + } + static String? _userId; static Future _getUserAppId() async { if (_userId == null) { @@ -41,7 +52,12 @@ class VioletServerV2 { } static Future getComments(String where) async { - return (await VioletServerV2.instance.apiV2CommentGet(where: where)).body!; + return (await VioletServerV2.sessionInstance.apiV2CommentGet(where: where)) + .body!; + } + + static Future postComment(CommentPostDto comment) async { + await VioletServerV2.sessionInstance.apiV2CommentPost(body: comment); } } @@ -64,3 +80,107 @@ class HmacInterceptor implements Interceptor { }; } } + +class SessionInterceptor implements Interceptor { + final bool withRefresh; + + SessionInterceptor({this.withRefresh = true}); + + @override + FutureOr> intercept( + Chain chain) async { + final request = applyHeaders(chain.request, sessionHeader(withRefresh)); + return chain.proceed(request); + } + + static Map sessionHeader(bool withRefresh) { + return { + 'cookie': + 'jwt-access=${SessionManager.accessToken.value};${withRefresh ? 'jwt-refresh=${SessionManager.refreshToken.value}' : ''}', + }; + } +} + +class SessionManager { + static final accessToken = SettingItem('accessToken', ''); + static final refreshToken = SettingItem('refreshToken', ''); + + static Future refresh() async { + if (accessToken.value.isEmpty || + refreshToken.value.isEmpty || + isExpired(refreshToken.value)) { + await login(); + } + + if (isExpired(accessToken.value)) { + try { + await refreshRefreshToken(); + } catch (_) { + await login(); + } + } + + if (isExpired(refreshToken.value) || isExpired(accessToken.value)) { + await Logger.error( + '[Server] Token unexpectedly expired, please contact developer.'); + } + + VioletServerV2.initSession(); + } + + static Future refreshRefreshToken() async { + final client = Api.create( + baseUrl: Uri.parse(VioletServerV2.api), + interceptors: [HmacInterceptor(), SessionInterceptor(withRefresh: true)], + ); + final res = await client.apiV2AuthRefreshGet(); + final tokens = res.body!.toJson()['tokens'] as Tokens; + + accessToken.setValue(tokens.accessToken); + Logger.info('[Server] Refreshed successfully'); + } + + static Future login() async { + final userId = await VioletServerV2._getUserAppId(); + var res = await VioletServerV2.instance + .apiV2AuthPost(body: UserRegisterDTO(userAppId: userId)); + + // register required + if (res.statusCode == 400) { + await VioletServerV2.instance + .apiV2UserPost(body: UserRegisterDTO(userAppId: userId)); + res = await VioletServerV2.instance + .apiV2AuthPost(body: UserRegisterDTO(userAppId: userId)); + } + + if (res.statusCode == 400) { + await Logger.error('[Server] Failed to login, please contact developer.'); + return; + } + + final tokens = res.body!; + + accessToken.setValue(tokens.accessToken); + refreshToken.setValue(tokens.refreshToken); + + await Logger.info('[Server] Logged in successfully'); + } + + static DateTime getRefreshExpiry(String token) { + return JwtDecoder.getExpirationDate(token); + } + + static Duration remaining(String token) { + return JwtDecoder.getRemainingTime(token); + } + + static bool isExpired(String token) { + return JwtDecoder.isExpired(token); + } + + static Future userInfo() async { + final res = await VioletServerV2.sessionInstance.apiV2UserGet(); + final user = res.body!; + print(user); + } +} diff --git a/violet/pubspec.lock b/violet/pubspec.lock index be850bc1c..4a9bff298 100644 --- a/violet/pubspec.lock +++ b/violet/pubspec.lock @@ -1168,6 +1168,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.9.3" + jwt_decoder: + dependency: "direct main" + description: + name: jwt_decoder + sha256: "54774aebf83f2923b99e6416b4ea915d47af3bde56884eb622de85feabbc559f" + url: "https://pub.dev" + source: hosted + version: "2.0.1" kdtree: dependency: "direct main" description: diff --git a/violet/pubspec.yaml b/violet/pubspec.yaml index 26da011e7..bd1c2102c 100644 --- a/violet/pubspec.yaml +++ b/violet/pubspec.yaml @@ -117,6 +117,7 @@ dependencies: git: https://github.com/donkizzy/image_crop image_size_getter: ^2.1.2 intl: ^0.19.0 + jwt_decoder: ^2.0.1 kdtree: ^0.2.0 local_auth: ^2.1.6 lottie: diff --git a/violet/swaggers/api.yaml b/violet/swaggers/api.yaml index ee32bb169..9cc2b4bc1 100644 --- a/violet/swaggers/api.yaml +++ b/violet/swaggers/api.yaml @@ -408,9 +408,6 @@ components: - updatedAt - userAppId - role - - discordId - - avatar - - nickname UserRegisterDTO: type: object properties: