feat: applied auth wirth session token on dart backend

This commit is contained in:
Jean Jacques Avril 2025-01-04 14:50:08 +00:00
parent 55edac6abe
commit e9221f36ef
No known key found for this signature in database
13 changed files with 146 additions and 236 deletions

View File

@ -1,11 +1,14 @@
import 'dart:convert';
import 'package:backend_dart/application/service/dto/auth_dto.dart';
import 'package:backend_dart/application/service/helper.dart';
import 'package:backend_dart/common/request_helper.dart';
import 'package:backend_dart/common/response_helpers.dart';
import 'package:backend_dart/common/secure_hash.dart';
import 'package:backend_dart/common/validation.dart';
import 'package:backend_dart/domain/entities/user.dart';
import 'package:backend_dart/domain/errors/app_error.dart';
import 'package:backend_dart/domain/errors/error.dart';
import 'package:backend_dart/domain/repository/auth_repository.dart';
import 'package:backend_dart/domain/repository/user_repository.dart';
import 'package:fpdart/fpdart.dart';
@ -37,20 +40,28 @@ class AuthService {
),
),
)
.flatMap((user) => authRepository.generateToken(user.id).map((token) =>
TokenResponseDTO(token: token, userId: user.id).toJson()))
.map(jsonEncode)
.toResponse()
.flatMap((user) => authRepository.generateToken(user.id).map(
(token) => Response.ok(
jsonEncode(
TokenResponseDTO(token: token, userId: user.id).toJson(),
),
headers: {
'Set-Cookie':
'session_token=$token; HttpOnly; Path=/, user_id=${user.id}; HttpOnly; Path=/',
'Content-Type': 'application/json',
},
),
))
.getOrElse(ResponseHelpers.fromError)
.run();
}
/// Route to validate a token
@Route.post('/validate')
Future<Response> validateToken(Request request) async {
return requestToJson(request)
.flatMap(validateJsonKeys(['token']))
.flatMap((json) => decodeJson(json, TokenRequestDTO.fromJson))
.map((tokenRequest) => tokenRequest.token)
return readCookie(request, 'session_token')
.toTaskEither<IError>(() => AppError.authenticationError(
message: 'No token found in the request'))
.flatMap((token) => authRepository.validateToken(token).map((userId) =>
TokenResponseDTO(token: token, userId: userId).toJson()))
.map(jsonEncode)
@ -61,14 +72,20 @@ class AuthService {
/// Route to revoke a token
@Route.post('/logout')
Future<Response> logout(Request request) async {
return requestToJson(request)
.flatMap(validateJsonKeys(['token']))
.flatMap((json) => decodeJson(json, TokenRequestDTO.fromJson))
.map((tokenRequest) => tokenRequest.token)
return readCookie(request, 'session_token')
.toTaskEither<IError>(() => AppError.authenticationError(
message: 'No token found in the request'))
.flatMap(authRepository.revokeToken)
.map((_) => {'message': 'Token revoked successfully'})
.map(jsonEncode)
.toResponse()
.map((_) => Response.ok(
jsonEncode({'message': 'Token revoked successfully'}),
headers: {
// Clear cookies by setting Max-Age to 0
'Set-Cookie':
'session_token=; Max-Age=0; HttpOnly; Path=/, user_id=; Max-Age=0; HttpOnly; Path=/',
'Content-Type': 'application/json',
},
))
.getOrElse(ResponseHelpers.fromError)
.run();
}

View File

@ -16,18 +16,6 @@ class TokenResponseDTO with _$TokenResponseDTO {
_$TokenResponseDTOFromJson(json);
}
/// TokenRequestDTO represents a request for operations involving tokens.
@freezed
class TokenRequestDTO with _$TokenRequestDTO {
const factory TokenRequestDTO({
required String token,
}) = _TokenRequestDTO;
/// JSON serialization
factory TokenRequestDTO.fromJson(Map<String, dynamic> json) =>
_$TokenRequestDTOFromJson(json);
}
/// LoginRequestDTO represents the login request.
@freezed
class LoginRequestDTO with _$LoginRequestDTO {

View File

@ -182,157 +182,6 @@ abstract class _TokenResponseDTO implements TokenResponseDTO {
throw _privateConstructorUsedError;
}
TokenRequestDTO _$TokenRequestDTOFromJson(Map<String, dynamic> json) {
return _TokenRequestDTO.fromJson(json);
}
/// @nodoc
mixin _$TokenRequestDTO {
String get token => throw _privateConstructorUsedError;
/// Serializes this TokenRequestDTO to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of TokenRequestDTO
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$TokenRequestDTOCopyWith<TokenRequestDTO> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $TokenRequestDTOCopyWith<$Res> {
factory $TokenRequestDTOCopyWith(
TokenRequestDTO value, $Res Function(TokenRequestDTO) then) =
_$TokenRequestDTOCopyWithImpl<$Res, TokenRequestDTO>;
@useResult
$Res call({String token});
}
/// @nodoc
class _$TokenRequestDTOCopyWithImpl<$Res, $Val extends TokenRequestDTO>
implements $TokenRequestDTOCopyWith<$Res> {
_$TokenRequestDTOCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of TokenRequestDTO
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? token = null,
}) {
return _then(_value.copyWith(
token: null == token
? _value.token
: token // ignore: cast_nullable_to_non_nullable
as String,
) as $Val);
}
}
/// @nodoc
abstract class _$$TokenRequestDTOImplCopyWith<$Res>
implements $TokenRequestDTOCopyWith<$Res> {
factory _$$TokenRequestDTOImplCopyWith(_$TokenRequestDTOImpl value,
$Res Function(_$TokenRequestDTOImpl) then) =
__$$TokenRequestDTOImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({String token});
}
/// @nodoc
class __$$TokenRequestDTOImplCopyWithImpl<$Res>
extends _$TokenRequestDTOCopyWithImpl<$Res, _$TokenRequestDTOImpl>
implements _$$TokenRequestDTOImplCopyWith<$Res> {
__$$TokenRequestDTOImplCopyWithImpl(
_$TokenRequestDTOImpl _value, $Res Function(_$TokenRequestDTOImpl) _then)
: super(_value, _then);
/// Create a copy of TokenRequestDTO
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? token = null,
}) {
return _then(_$TokenRequestDTOImpl(
token: null == token
? _value.token
: token // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
@JsonSerializable()
class _$TokenRequestDTOImpl implements _TokenRequestDTO {
const _$TokenRequestDTOImpl({required this.token});
factory _$TokenRequestDTOImpl.fromJson(Map<String, dynamic> json) =>
_$$TokenRequestDTOImplFromJson(json);
@override
final String token;
@override
String toString() {
return 'TokenRequestDTO(token: $token)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$TokenRequestDTOImpl &&
(identical(other.token, token) || other.token == token));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, token);
/// Create a copy of TokenRequestDTO
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$TokenRequestDTOImplCopyWith<_$TokenRequestDTOImpl> get copyWith =>
__$$TokenRequestDTOImplCopyWithImpl<_$TokenRequestDTOImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$TokenRequestDTOImplToJson(
this,
);
}
}
abstract class _TokenRequestDTO implements TokenRequestDTO {
const factory _TokenRequestDTO({required final String token}) =
_$TokenRequestDTOImpl;
factory _TokenRequestDTO.fromJson(Map<String, dynamic> json) =
_$TokenRequestDTOImpl.fromJson;
@override
String get token;
/// Create a copy of TokenRequestDTO
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$TokenRequestDTOImplCopyWith<_$TokenRequestDTOImpl> get copyWith =>
throw _privateConstructorUsedError;
}
LoginRequestDTO _$LoginRequestDTOFromJson(Map<String, dynamic> json) {
return _LoginRequestDTO.fromJson(json);
}

View File

@ -20,18 +20,6 @@ Map<String, dynamic> _$$TokenResponseDTOImplToJson(
'userId': instance.userId,
};
_$TokenRequestDTOImpl _$$TokenRequestDTOImplFromJson(
Map<String, dynamic> json) =>
_$TokenRequestDTOImpl(
token: json['token'] as String,
);
Map<String, dynamic> _$$TokenRequestDTOImplToJson(
_$TokenRequestDTOImpl instance) =>
<String, dynamic>{
'token': instance.token,
};
_$LoginRequestDTOImpl _$$LoginRequestDTOImplFromJson(
Map<String, dynamic> json) =>
_$LoginRequestDTOImpl(

View File

@ -0,0 +1,33 @@
import 'package:backend_dart/domain/errors/app_error.dart';
import 'package:backend_dart/domain/errors/error.dart';
import 'package:backend_dart/domain/repository/auth_repository.dart';
import 'package:fpdart/fpdart.dart';
import 'package:shelf/shelf.dart';
TaskOption<String> readCookie(Request request, String cookieName) {
// Retrieve the "Cookie" header
final cookieHeader = request.headers['cookie'];
if (cookieHeader == null) return TaskOption.none();
// No cookies found
// Split the cookies into individual key-value pairs
final cookies = cookieHeader.split(';');
// Find the cookie with the matching name
for (var cookie in cookies) {
final parts = cookie.trim().split('=');
if (parts.length == 2 && parts[0] == cookieName && parts[1].isNotEmpty) {
return TaskOption.some(parts[1]);
}
}
return TaskOption.none(); // Cookie not found
}
TaskEither<IError, String> checkAuth(
Request request, AuthRepository authRepository) {
return readCookie(request, 'session_token')
.toTaskEither<IError>(() => AppError.authenticationError(
message: 'No token found in the request'))
.flatMap(authRepository.validateToken);
}

View File

@ -1,10 +1,12 @@
import 'dart:convert';
import 'package:backend_dart/application/service/dto/project_dto.dart';
import 'package:backend_dart/application/service/helper.dart';
import 'package:backend_dart/application/service/mapper/project_dto_mapper.dart';
import 'package:backend_dart/common/request_helper.dart';
import 'package:backend_dart/common/response_helpers.dart';
import 'package:backend_dart/common/validation.dart';
import 'package:backend_dart/domain/repository/auth_repository.dart';
import 'package:backend_dart/domain/repository/project_repository.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
@ -13,13 +15,14 @@ part 'project_service.g.dart'; // generated with 'pub run build_runner build'
class ProjectService {
final ProjectRepository projects;
final AuthRepository authRepository;
final ProjectDtoMapper mapper = ProjectDtoMapper();
ProjectService(this.projects);
ProjectService(this.projects, this.authRepository);
@Route.get('/')
Future<Response> listProjects(Request request) async {
return projects
.findAll()
return checkAuth(request, authRepository)
.flatMap((_) => projects.findAll())
.flatMap(mapper.listTo)
.map((projects) => projects.map((project) => project.toJson()).toList())
.map(jsonEncode)
@ -29,8 +32,8 @@ class ProjectService {
@Route.get('/<projectId>')
Future<Response> fetchProject(Request request, String projectId) async {
return projects
.findById(projectId)
return checkAuth(request, authRepository)
.flatMap((_) => projects.findById(projectId))
.flatMap(mapper.to)
.map((dto) => dto.toJson())
.toResponse()
@ -39,7 +42,8 @@ class ProjectService {
@Route.post('/')
Future<Response> createProject(Request request) async {
return requestToJson(request)
return checkAuth(request, authRepository)
.flatMap((_) => requestToJson(request))
.flatMap(validateJsonKeys(['name', 'userId'])) // Add required fields
.flatMap((json) => decodeJson(json, ProjectCreateDto.fromJson))
.flatMap(mapper.fromCreateTo)
@ -52,7 +56,8 @@ class ProjectService {
@Route.put('/<projectId>')
Future<Response> updateProject(Request request, String projectId) async {
return requestToJson(request)
return checkAuth(request, authRepository)
.flatMap((_) => requestToJson(request))
.flatMap(validateJsonKeys([]))
.flatMap((json) => decodeJson(json, ProjectUpdateDto.fromJson))
.flatMap((dto) => mapper.fromUpdateTo(dto, projectId))
@ -65,8 +70,8 @@ class ProjectService {
@Route.delete('/<projectId>')
Future<Response> deleteProject(Request request, String projectId) async {
return projects
.delete(projectId)
return checkAuth(request, authRepository)
.flatMap((_) => projects.delete(projectId))
.flatMap(mapper.to)
.map((dto) => dto.toJson())
.toResponse()

View File

@ -1,10 +1,12 @@
import 'dart:convert';
import 'package:backend_dart/application/service/dto/project_task_dto.dart';
import 'package:backend_dart/application/service/helper.dart';
import 'package:backend_dart/application/service/mapper/project_task_dto_mapper.dart';
import 'package:backend_dart/common/request_helper.dart';
import 'package:backend_dart/common/response_helpers.dart';
import 'package:backend_dart/common/validation.dart';
import 'package:backend_dart/domain/repository/auth_repository.dart';
import 'package:backend_dart/domain/repository/project_task_repository.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
@ -13,13 +15,15 @@ part 'project_task_service.g.dart'; // Generated with 'pub run build_runner buil
class ProjectTaskService {
final ProjectTaskRepository tasks;
final AuthRepository authRepository;
final ProjectTaskDtoMapper mapper = ProjectTaskDtoMapper();
ProjectTaskService(this.tasks);
ProjectTaskService(this.tasks, this.authRepository);
@Route.get('/')
Future<Response> listTasks(Request request) async {
return tasks
.findAll()
return checkAuth(request, authRepository)
.flatMap((_) => tasks.findAll())
.flatMap(mapper.listTo)
.map((tasks) => tasks.map((task) => task.toJson()).toList())
.map(jsonEncode)
@ -29,8 +33,8 @@ class ProjectTaskService {
@Route.get('/<taskId>')
Future<Response> fetchTask(Request request, String taskId) async {
return tasks
.findById(taskId)
return checkAuth(request, authRepository)
.flatMap((_) => tasks.findById(taskId))
.flatMap(mapper.to)
.map((dto) => dto.toJson())
.toResponse()
@ -39,7 +43,8 @@ class ProjectTaskService {
@Route.post('/')
Future<Response> createTask(Request request) async {
return requestToJson(request)
return checkAuth(request, authRepository)
.flatMap((_) => requestToJson(request))
.flatMap(validateJsonKeys(['name', 'projectId'])) // Add required fields
.flatMap((json) => decodeJson(json, ProjectTaskCreateDto.fromJson))
.flatMap(mapper.fromCreateTo)
@ -52,7 +57,8 @@ class ProjectTaskService {
@Route.put('/<taskId>')
Future<Response> updateTask(Request request, String taskId) async {
return requestToJson(request)
return checkAuth(request, authRepository)
.flatMap((_) => requestToJson(request))
.flatMap(validateJsonKeys([]))
.flatMap((json) => decodeJson(json, ProjectTaskUpdateDto.fromJson))
.flatMap((dto) => mapper.fromUpdateTo(dto, taskId))
@ -65,8 +71,8 @@ class ProjectTaskService {
@Route.delete('/<taskId>')
Future<Response> deleteTask(Request request, String taskId) async {
return tasks
.delete(taskId)
return checkAuth(request, authRepository)
.flatMap((_) => tasks.delete(taskId))
.flatMap(mapper.to)
.map((dto) => dto.toJson())
.toResponse()

View File

@ -8,22 +8,26 @@ import 'package:riverpod/riverpod.dart';
final userServiceProvider = Provider<UserService>((ref) {
final database = ref.read(userRepoProvider);
return UserService(database);
final authRepository = ref.read(authProvider);
return UserService(database, authRepository);
});
final projectServiceProvider = Provider<ProjectService>((ref) {
final database = ref.read(projectProvider);
return ProjectService(database);
final authRepository = ref.read(authProvider);
return ProjectService(database, authRepository);
});
final projectTaskServiceProvider = Provider<ProjectTaskService>((ref) {
final database = ref.read(projectTaskProvider);
return ProjectTaskService(database);
final authRepository = ref.read(authProvider);
return ProjectTaskService(database, authRepository);
});
final timeEntryServiceProvider = Provider<TimeEntryService>((ref) {
final database = ref.read(timeEntryProvider);
return TimeEntryService(database);
final authRepository = ref.read(authProvider);
return TimeEntryService(database, authRepository);
});
final authServiceProvider = Provider<AuthService>((ref) {

View File

@ -1,10 +1,12 @@
import 'dart:convert';
import 'package:backend_dart/application/service/dto/time_entry_dto.dart';
import 'package:backend_dart/application/service/helper.dart';
import 'package:backend_dart/application/service/mapper/time_entry_dto_mapper.dart';
import 'package:backend_dart/common/request_helper.dart';
import 'package:backend_dart/common/response_helpers.dart';
import 'package:backend_dart/common/validation.dart';
import 'package:backend_dart/domain/repository/auth_repository.dart';
import 'package:backend_dart/domain/repository/time_entry_repository.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
@ -13,13 +15,14 @@ part 'time_entry_service.g.dart'; // Generated with 'pub run build_runner build'
class TimeEntryService {
final TimeEntryRepository entries;
final AuthRepository authRepository;
final TimeEntryDtoMapper mapper = TimeEntryDtoMapper();
TimeEntryService(this.entries);
TimeEntryService(this.entries, this.authRepository);
@Route.get('/')
Future<Response> listEntries(Request request) async {
return entries
.findAll()
return checkAuth(request, authRepository)
.flatMap((_) => entries.findAll())
.flatMap(mapper.listTo)
.map((entries) => entries.map((entry) => entry.toJson()).toList())
.map(jsonEncode)
@ -29,8 +32,8 @@ class TimeEntryService {
@Route.get('/<entryId>')
Future<Response> fetchEntry(Request request, String entryId) async {
return entries
.findById(entryId)
return checkAuth(request, authRepository)
.flatMap((_) => entries.findById(entryId))
.flatMap(mapper.to)
.map((dto) => dto.toJson())
.toResponse()
@ -39,7 +42,8 @@ class TimeEntryService {
@Route.post('/')
Future<Response> createEntry(Request request) async {
return requestToJson(request)
return checkAuth(request, authRepository)
.flatMap((_) => requestToJson(request))
.flatMap(
validateJsonKeys(['startTime', 'endTime', 'userId', 'projectId']))
.flatMap((json) => decodeJson(json, TimeEntryCreateDto.fromJson))
@ -53,7 +57,8 @@ class TimeEntryService {
@Route.put('/<entryId>')
Future<Response> updateEntry(Request request, String entryId) async {
return requestToJson(request)
return checkAuth(request, authRepository)
.flatMap((_) => requestToJson(request))
.flatMap(validateJsonKeys([]))
.flatMap((json) => decodeJson(json, TimeEntryUpdateDto.fromJson))
.flatMap((dto) => mapper.fromUpdateTo(dto, entryId))
@ -66,8 +71,8 @@ class TimeEntryService {
@Route.delete('/<entryId>')
Future<Response> deleteEntry(Request request, String entryId) async {
return entries
.delete(entryId)
return checkAuth(request, authRepository)
.flatMap((_) => entries.delete(entryId))
.map((dto) => dto.toJson())
.toResponse()
.run();

View File

@ -1,10 +1,12 @@
import 'dart:convert';
import 'package:backend_dart/application/service/dto/user_dto.dart';
import 'package:backend_dart/application/service/helper.dart';
import 'package:backend_dart/application/service/mapper/user_dto_mapper.dart';
import 'package:backend_dart/common/request_helper.dart';
import 'package:backend_dart/common/response_helpers.dart';
import 'package:backend_dart/common/validation.dart';
import 'package:backend_dart/domain/repository/auth_repository.dart';
import 'package:backend_dart/domain/repository/user_repository.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
@ -13,13 +15,15 @@ part 'user_service.g.dart'; // generated with 'pub run build_runner build'
class UserService {
final UserRepository users;
final AuthRepository authRepository;
final UserDtoMapper mapper = UserDtoMapper();
UserService(this.users);
UserService(this.users, this.authRepository);
@Route.get('/')
Future<Response> listUsers(Request request) async {
return users
.findAll()
return checkAuth(request, authRepository)
.flatMap((_) => users.findAll())
.flatMap(mapper.listTo)
.map((users) => users.map((user) => user.toJson()).toList())
.map(jsonEncode)
@ -29,8 +33,8 @@ class UserService {
@Route.get('/<userId>')
Future<Response> fetchUser(Request request, String userId) async {
return users
.findById(userId)
return checkAuth(request, authRepository)
.flatMap((_) => users.findById(userId))
.flatMap(mapper.to)
.map((dto) => dto.toJson())
.toResponse()
@ -48,11 +52,22 @@ class UserService {
.map((dto) => dto.toJson())
.toResponse()
.run();
//return checkAuth(request, authRepository) // ignore auth for initial user creation
// .flatMap((_) => requestToJson(request))
// .flatMap(validateJsonKeys(['name', 'email', 'password']))
// .flatMap((json) => decodeJson(json, UserCreateDto.fromJson))
// .flatMap(mapper.fromCreateTo)
// .flatMap(users.create)
// .flatMap(mapper.to)
// .map((dto) => dto.toJson())
// .toResponse()
// .run();
}
@Route.put('/<userId>')
Future<Response> updateUser(Request request, String userId) async {
return requestToJson(request)
return checkAuth(request, authRepository)
.flatMap((_) => requestToJson(request))
.flatMap(validateJsonKeys([]))
.flatMap((json) => decodeJson(json, UserUpdateDto.fromJson))
.flatMap((dto) => mapper.fromUpdateTo(dto, userId))
@ -65,8 +80,8 @@ class UserService {
@Route.delete('/<userId>')
Future<Response> deleteUser(Request request, String userId) async {
return users
.delete(userId)
return checkAuth(request, authRepository)
.flatMap((_) => users.delete(userId))
.flatMap(mapper.to)
.map((dto) => dto.toJson())
.toResponse()

View File

@ -14,8 +14,8 @@ extension TaskEitherResponseExtensions
extension TaskEitherResponseExtensionsFromString on TaskEither<IError, String> {
Task<Response> toResponse() => match(
(left) => ResponseHelpers.fromError(left),
(right) => Response.ok(right),
ResponseHelpers.fromError,
Response.ok,
);
}

View File

@ -106,15 +106,15 @@ func (s *AuthService) Logout(c *gin.Context) {
func setSessionTokenCookie(c *gin.Context, user entities.User) func(token string) string {
return func(token string) string {
c.SetCookie("session_token", token, 3600, "/", "localhost", false, true)
c.SetCookie("user_id", user.ID, 3600, "/", "localhost", false, true)
c.SetCookie("session_token", token, 3600, "/", "", false, true)
c.SetCookie("user_id", user.ID, 3600, "/", "", false, true)
return token
}
}
func deleteSessionTokenCookie(c *gin.Context) {
c.SetCookie("session_token", "", -1, "/", "localhost", false, true)
c.SetCookie("user_id", "", -1, "/", "localhost", false, true)
c.SetCookie("session_token", "", -1, "/", "", false, true)
c.SetCookie("user_id", "", -1, "/", "", false, true)
}
func validatePassword(password string) func(user entities.User) E.Either[error, entities.User] {

View File

@ -3,7 +3,7 @@ package dto
// TokenResponseDTO represents the response for a token generation or validation.
type TokenResponseDTO struct {
Token string `json:"token"`
UserID string `json:"user_id"`
UserID string `json:"userId"`
}
// LoginRequestDTO represents the login request.