From b1ec8284b2e0d9c0b7c41e61dd3ecc5e9f7e5b52 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Wed, 16 Sep 2026 19:06:52 +0700 Subject: [PATCH] add users --- internal/app/server.go | 240 -------------------------------------- internal/app/users.go | 256 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+), 240 deletions(-) create mode 100644 internal/app/users.go diff --git a/internal/app/server.go b/internal/app/server.go index 5b64ecb..e03827d 100644 --- a/internal/app/server.go +++ b/internal/app/server.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "net/netip" "strings" "evening_detective_server/docs" @@ -16,7 +15,6 @@ import ( "evening_detective_server/internal/repos/game_map_repo" "evening_detective_server/internal/repos/scenarios_repo" "evening_detective_server/internal/repos/screen_sessions_repo" - "evening_detective_server/internal/repos/users_repo" "evening_detective_server/internal/services/file_service" "evening_detective_server/internal/services/game_service" "evening_detective_server/internal/services/scenarios_service" @@ -74,48 +72,6 @@ func (s *server) Echo(_ context.Context, req *proto.EchoReq) (*proto.EchoRsp, er return &proto.EchoRsp{Text: req.Text}, nil } -func (s *server) Signup(ctx context.Context, req *proto.SignupReq) (*proto.SignupRsp, error) { - ip, userAgent := clientInfoFromContext(ctx) - err := s.usersService.AddUser( - ctx, - req.Username, - req.Email, - req.AcceptTerms, - req.AcceptPrivacy, - ip, - userAgent, - ) - if err != nil { - return &proto.SignupRsp{ - Error: err.Error(), - }, nil - } - return &proto.SignupRsp{}, nil -} - -func (s *server) DeleteAccount(ctx context.Context, req *proto.DeleteAccountReq) (*proto.DeleteAccountRsp, error) { - claims, ok := ctx.Value("claims").(*processor_jwt.JWTClaims) - if !ok { - return nil, status.Errorf(codes.Unauthenticated, "claims is not provided") - } - // Пароль приходит HTTP-заголовком X-Password (пробрасывается матчером - // в main.go), чтобы креденшел не попадал в URL и логи. Для прямых - // gRPC-клиентов заголовок передаётся в metadata под тем же ключом. - password := "" - if md, ok := metadata.FromIncomingContext(ctx); ok { - if values := md.Get("x-password"); len(values) > 0 { - password = values[0] - } - } - err := s.usersService.DeleteAccount(ctx, claims.UserID, password) - if err != nil { - return &proto.DeleteAccountRsp{ - Error: err.Error(), - }, nil - } - return &proto.DeleteAccountRsp{}, nil -} - // GetTerms возвращает текст Пользовательского соглашения (постоянный URL // /api/terms, на который ссылается форма регистрации при акцепте). func (s *server) GetTerms(_ context.Context, _ *proto.GetTermsReq) (*proto.GetTermsRsp, error) { @@ -146,180 +102,6 @@ func (s *server) GetConsent(_ context.Context, _ *proto.GetConsentReq) (*proto.G return &proto.GetConsentRsp{Text: string(text)}, nil } -// clientInfoFromContext извлекает IP-адрес и user-agent из gRPC-метаданных. -// -// grpc-gateway пробрасывает HTTP-заголовки в метаданные так -// (runtime.annotateContext / runtime.DefaultHeaderMatcher): -// - x-forwarded-for — всегда, при этом gateway дописывает реальный RemoteAddr -// клиента последним элементом списка; -// - постоянные HTTP-заголовки (включая User-Agent) — с префиксом -// grpcgateway-; -// - остальные заголовки по умолчанию не пробрасываются: X-Real-IP и -// X-Password включаются явным матчером в main.go. -// -// Полученный IP валидируется netip.ParseAddr: при прямой экспозиции gateway -// клиент может подделать эти заголовки, поэтому в БД попадает только -// корректный адрес. -func clientInfoFromContext(ctx context.Context) (ip string, userAgent string) { - md, ok := metadata.FromIncomingContext(ctx) - if !ok { - return "", "" - } - - // X-Real-IP от доверенного прокси — приоритетен: в этом случае последним - // элементом X-Forwarded-For является адрес прокси, а не клиента. - if values := md.Get("x-real-ip"); len(values) > 0 { - if v := strings.TrimSpace(values[0]); v != "" { - if _, err := netip.ParseAddr(v); err == nil { - ip = v - } - } - } - if ip == "" { - if values := md.Get("x-forwarded-for"); len(values) > 0 { - parts := strings.Split(values[0], ",") - // Идём с конца списка: последний элемент — реальный RemoteAddr - // клиента (первые элементы мог подделать сам клиент). - for i := len(parts) - 1; i >= 0; i-- { - v := strings.TrimSpace(parts[i]) - if v == "" { - continue - } - if _, err := netip.ParseAddr(v); err == nil { - ip = v - break - } - } - } - } - - if values := md.Get("grpcgateway-user-agent"); len(values) > 0 { - userAgent = values[0] - } else if values := md.Get("user-agent"); len(values) > 0 { - userAgent = values[0] - } - - return ip, userAgent -} - -func (s *server) RefreshPassword(ctx context.Context, req *proto.RefreshPasswordReq) (*proto.RefreshPasswordRsp, error) { - err := s.usersService.RefreshPassword(ctx, req.Email) - if err != nil { - return &proto.RefreshPasswordRsp{ - Error: err.Error(), - }, nil - } - return &proto.RefreshPasswordRsp{}, nil -} - -func (s *server) Login(ctx context.Context, req *proto.LoginReq) (*proto.LoginRsp, error) { - accessToken, refreshToken, err := s.usersService.Login(ctx, req.Email, req.Password) - if err != nil { - return &proto.LoginRsp{ - Error: err.Error(), - }, nil - } - return &proto.LoginRsp{ - AccessToken: accessToken, - RefreshToken: refreshToken, - }, nil -} - -func (s *server) Refresh(ctx context.Context, req *proto.RefreshReq) (*proto.RefreshRsp, error) { - accessToken, refreshToken, err := s.usersService.Refresh(ctx, req.RefreshToken) - if err != nil { - return &proto.RefreshRsp{ - Error: err.Error(), - }, nil - } - return &proto.RefreshRsp{ - AccessToken: accessToken, - RefreshToken: refreshToken, - }, nil -} - -func (s *server) GetUsers(ctx context.Context, req *proto.GetUsersReq) (*proto.GetUsersRsp, error) { - claims := ctx.Value("claims").(*processor_jwt.JWTClaims) - if !roles.HasRole(claims, roles.Admin) { - return nil, status.Errorf(codes.PermissionDenied, "permission denied") - } - users, err := s.usersService.GetUsers(ctx) - if err != nil { - return &proto.GetUsersRsp{ - Error: err.Error(), - }, nil - } - - resUsers := make([]*proto.User, 0, len(users)) - for _, user := range users { - resUsers = append(resUsers, mapUser(user)) - } - return &proto.GetUsersRsp{ - Users: resUsers, - }, nil -} - -func (s *server) GetUserById(ctx context.Context, req *proto.GetUserByIdReq) (*proto.GetUserByIdRsp, error) { - user, err := s.usersService.GetUserByID(ctx, int(req.Id)) - if err != nil { - return &proto.GetUserByIdRsp{ - Error: err.Error(), - }, nil - } - - return &proto.GetUserByIdRsp{ - User: mapUser(user), - }, nil -} - -func (s *server) GetMe(ctx context.Context, req *proto.GetMeReq) (*proto.GetMeRsp, error) { - claims := ctx.Value("claims").(*processor_jwt.JWTClaims) - user, err := s.usersService.GetUserByID(ctx, claims.UserID) - if err != nil { - return &proto.GetMeRsp{ - Error: err.Error(), - }, nil - } - - return &proto.GetMeRsp{ - User: mapUser(user), - }, nil -} - -func (s *server) AddUserRole(ctx context.Context, req *proto.AddUserRoleReq) (*proto.AddUserRoleRsp, error) { - claims := ctx.Value("claims").(*processor_jwt.JWTClaims) - if !roles.HasRole(claims, roles.Admin) { - return nil, status.Errorf(codes.PermissionDenied, "permission denied") - } - if err := s.usersService.AddUserRole(ctx, int(req.Id), req.Role); err != nil { - return &proto.AddUserRoleRsp{ - Error: err.Error(), - }, nil - } - return &proto.AddUserRoleRsp{}, nil -} - -func (s *server) DeleteUserRole(ctx context.Context, req *proto.DeleteUserRoleReq) (*proto.DeleteUserRoleRsp, error) { - claims := ctx.Value("claims").(*processor_jwt.JWTClaims) - if !roles.HasRole(claims, roles.Admin) { - return nil, status.Errorf(codes.PermissionDenied, "permission denied") - } - if err := s.usersService.DeleteUserRole(ctx, int(req.Id), req.Role); err != nil { - return &proto.DeleteUserRoleRsp{ - Error: err.Error(), - }, nil - } - return &proto.DeleteUserRoleRsp{}, nil -} - -func (s *server) GetPermissions(ctx context.Context, req *proto.GetPermissionsReq) (*proto.GetPermissionsRsp, error) { - claims := ctx.Value("claims").(*processor_jwt.JWTClaims) - permissions := s.uiService.GetPermissions(claims.Roles) - return &proto.GetPermissionsRsp{ - Permissions: permissions, - }, nil -} - func (s *server) UploadFile(ctx context.Context, req *proto.UploadFileReq) (*proto.UploadFileRsp, error) { // Файлы (в т.ч. улики) загружают авторы сценариев в редакторе. claims := ctx.Value("claims").(*processor_jwt.JWTClaims) @@ -1238,25 +1020,3 @@ func (s *server) SetTeamAnswerScore(ctx context.Context, req *proto.SetTeamAnswe return &proto.SetTeamAnswerScoreRsp{}, nil } - -func (s *server) ChangeUserPassword(ctx context.Context, req *proto.ChangeUserPasswordReq) (*proto.ChangeUserPasswordRsp, error) { - claims, ok := ctx.Value("claims").(*processor_jwt.JWTClaims) - if !ok || claims == nil { - return nil, status.Error(codes.Unauthenticated, "Требуется авторизация") - } - if !roles.HasRole(claims, roles.Admin) { - return nil, status.Error(codes.PermissionDenied, "Недостаточно прав") - } - err := s.usersService.ChangeUserPassword(ctx, claims.UserID, int(req.GetId()), req.GetNewPassword()) - switch { - case errors.Is(err, users_service.ErrPasswordChangeForbidden): - return nil, status.Error(codes.PermissionDenied, err.Error()) - case errors.Is(err, users_service.ErrInvalidPasswordChange): - return nil, status.Error(codes.InvalidArgument, err.Error()) - case errors.Is(err, users_repo.ErrUserNotFound): - return nil, status.Error(codes.NotFound, err.Error()) - case err != nil: - return nil, status.Error(codes.Internal, "Не удалось изменить пароль") - } - return &proto.ChangeUserPasswordRsp{}, nil -} diff --git a/internal/app/users.go b/internal/app/users.go new file mode 100644 index 0000000..e8f7692 --- /dev/null +++ b/internal/app/users.go @@ -0,0 +1,256 @@ +package app + +import ( + "context" + "errors" + "net/netip" + "strings" + + "evening_detective_server/internal/modules/processor_jwt" + "evening_detective_server/internal/modules/roles" + "evening_detective_server/internal/repos/users_repo" + "evening_detective_server/internal/services/users_service" + proto "evening_detective_server/proto" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +func (s *server) Signup(ctx context.Context, req *proto.SignupReq) (*proto.SignupRsp, error) { + ip, userAgent := clientInfoFromContext(ctx) + err := s.usersService.AddUser( + ctx, + req.Username, + req.Email, + req.AcceptTerms, + req.AcceptPrivacy, + ip, + userAgent, + ) + if err != nil { + return &proto.SignupRsp{ + Error: err.Error(), + }, nil + } + return &proto.SignupRsp{}, nil +} + +func (s *server) DeleteAccount(ctx context.Context, req *proto.DeleteAccountReq) (*proto.DeleteAccountRsp, error) { + claims, ok := ctx.Value("claims").(*processor_jwt.JWTClaims) + if !ok { + return nil, status.Errorf(codes.Unauthenticated, "claims is not provided") + } + // Пароль приходит HTTP-заголовком X-Password (пробрасывается матчером + // в main.go), чтобы креденшел не попадал в URL и логи. Для прямых + // gRPC-клиентов заголовок передаётся в metadata под тем же ключом. + password := "" + if md, ok := metadata.FromIncomingContext(ctx); ok { + if values := md.Get("x-password"); len(values) > 0 { + password = values[0] + } + } + err := s.usersService.DeleteAccount(ctx, claims.UserID, password) + if err != nil { + return &proto.DeleteAccountRsp{ + Error: err.Error(), + }, nil + } + return &proto.DeleteAccountRsp{}, nil +} + +// clientInfoFromContext извлекает IP-адрес и user-agent из gRPC-метаданных. +// +// grpc-gateway пробрасывает HTTP-заголовки в метаданные так +// (runtime.annotateContext / runtime.DefaultHeaderMatcher): +// - x-forwarded-for — всегда, при этом gateway дописывает реальный RemoteAddr +// клиента последним элементом списка; +// - постоянные HTTP-заголовки (включая User-Agent) — с префиксом +// grpcgateway-; +// - остальные заголовки по умолчанию не пробрасываются: X-Real-IP и +// X-Password включаются явным матчером в main.go. +// +// Полученный IP валидируется netip.ParseAddr: при прямой экспозиции gateway +// клиент может подделать эти заголовки, поэтому в БД попадает только +// корректный адрес. +func clientInfoFromContext(ctx context.Context) (ip string, userAgent string) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return "", "" + } + + // X-Real-IP от доверенного прокси — приоритетен: в этом случае последним + // элементом X-Forwarded-For является адрес прокси, а не клиента. + if values := md.Get("x-real-ip"); len(values) > 0 { + if v := strings.TrimSpace(values[0]); v != "" { + if _, err := netip.ParseAddr(v); err == nil { + ip = v + } + } + } + if ip == "" { + if values := md.Get("x-forwarded-for"); len(values) > 0 { + parts := strings.Split(values[0], ",") + // Идём с конца списка: последний элемент — реальный RemoteAddr + // клиента (первые элементы мог подделать сам клиент). + for i := len(parts) - 1; i >= 0; i-- { + v := strings.TrimSpace(parts[i]) + if v == "" { + continue + } + if _, err := netip.ParseAddr(v); err == nil { + ip = v + break + } + } + } + } + + if values := md.Get("grpcgateway-user-agent"); len(values) > 0 { + userAgent = values[0] + } else if values := md.Get("user-agent"); len(values) > 0 { + userAgent = values[0] + } + + return ip, userAgent +} + +func (s *server) RefreshPassword(ctx context.Context, req *proto.RefreshPasswordReq) (*proto.RefreshPasswordRsp, error) { + err := s.usersService.RefreshPassword(ctx, req.Email) + if err != nil { + return &proto.RefreshPasswordRsp{ + Error: err.Error(), + }, nil + } + return &proto.RefreshPasswordRsp{}, nil +} + +func (s *server) Login(ctx context.Context, req *proto.LoginReq) (*proto.LoginRsp, error) { + accessToken, refreshToken, err := s.usersService.Login(ctx, req.Email, req.Password) + if err != nil { + return &proto.LoginRsp{ + Error: err.Error(), + }, nil + } + return &proto.LoginRsp{ + AccessToken: accessToken, + RefreshToken: refreshToken, + }, nil +} + +func (s *server) Refresh(ctx context.Context, req *proto.RefreshReq) (*proto.RefreshRsp, error) { + accessToken, refreshToken, err := s.usersService.Refresh(ctx, req.RefreshToken) + if err != nil { + return &proto.RefreshRsp{ + Error: err.Error(), + }, nil + } + return &proto.RefreshRsp{ + AccessToken: accessToken, + RefreshToken: refreshToken, + }, nil +} + +func (s *server) GetUsers(ctx context.Context, req *proto.GetUsersReq) (*proto.GetUsersRsp, error) { + claims := ctx.Value("claims").(*processor_jwt.JWTClaims) + if !roles.HasRole(claims, roles.Admin) { + return nil, status.Errorf(codes.PermissionDenied, "permission denied") + } + users, err := s.usersService.GetUsers(ctx) + if err != nil { + return &proto.GetUsersRsp{ + Error: err.Error(), + }, nil + } + + resUsers := make([]*proto.User, 0, len(users)) + for _, user := range users { + resUsers = append(resUsers, mapUser(user)) + } + return &proto.GetUsersRsp{ + Users: resUsers, + }, nil +} + +func (s *server) GetUserById(ctx context.Context, req *proto.GetUserByIdReq) (*proto.GetUserByIdRsp, error) { + user, err := s.usersService.GetUserByID(ctx, int(req.Id)) + if err != nil { + return &proto.GetUserByIdRsp{ + Error: err.Error(), + }, nil + } + + return &proto.GetUserByIdRsp{ + User: mapUser(user), + }, nil +} + +func (s *server) GetMe(ctx context.Context, req *proto.GetMeReq) (*proto.GetMeRsp, error) { + claims := ctx.Value("claims").(*processor_jwt.JWTClaims) + user, err := s.usersService.GetUserByID(ctx, claims.UserID) + if err != nil { + return &proto.GetMeRsp{ + Error: err.Error(), + }, nil + } + + return &proto.GetMeRsp{ + User: mapUser(user), + }, nil +} + +func (s *server) AddUserRole(ctx context.Context, req *proto.AddUserRoleReq) (*proto.AddUserRoleRsp, error) { + claims := ctx.Value("claims").(*processor_jwt.JWTClaims) + if !roles.HasRole(claims, roles.Admin) { + return nil, status.Errorf(codes.PermissionDenied, "permission denied") + } + if err := s.usersService.AddUserRole(ctx, int(req.Id), req.Role); err != nil { + return &proto.AddUserRoleRsp{ + Error: err.Error(), + }, nil + } + return &proto.AddUserRoleRsp{}, nil +} + +func (s *server) DeleteUserRole(ctx context.Context, req *proto.DeleteUserRoleReq) (*proto.DeleteUserRoleRsp, error) { + claims := ctx.Value("claims").(*processor_jwt.JWTClaims) + if !roles.HasRole(claims, roles.Admin) { + return nil, status.Errorf(codes.PermissionDenied, "permission denied") + } + if err := s.usersService.DeleteUserRole(ctx, int(req.Id), req.Role); err != nil { + return &proto.DeleteUserRoleRsp{ + Error: err.Error(), + }, nil + } + return &proto.DeleteUserRoleRsp{}, nil +} + +func (s *server) GetPermissions(ctx context.Context, req *proto.GetPermissionsReq) (*proto.GetPermissionsRsp, error) { + claims := ctx.Value("claims").(*processor_jwt.JWTClaims) + permissions := s.uiService.GetPermissions(claims.Roles) + return &proto.GetPermissionsRsp{ + Permissions: permissions, + }, nil +} + +func (s *server) ChangeUserPassword(ctx context.Context, req *proto.ChangeUserPasswordReq) (*proto.ChangeUserPasswordRsp, error) { + claims, ok := ctx.Value("claims").(*processor_jwt.JWTClaims) + if !ok || claims == nil { + return nil, status.Error(codes.Unauthenticated, "Требуется авторизация") + } + if !roles.HasRole(claims, roles.Admin) { + return nil, status.Error(codes.PermissionDenied, "Недостаточно прав") + } + err := s.usersService.ChangeUserPassword(ctx, claims.UserID, int(req.GetId()), req.GetNewPassword()) + switch { + case errors.Is(err, users_service.ErrPasswordChangeForbidden): + return nil, status.Error(codes.PermissionDenied, err.Error()) + case errors.Is(err, users_service.ErrInvalidPasswordChange): + return nil, status.Error(codes.InvalidArgument, err.Error()) + case errors.Is(err, users_repo.ErrUserNotFound): + return nil, status.Error(codes.NotFound, err.Error()) + case err != nil: + return nil, status.Error(codes.Internal, "Не удалось изменить пароль") + } + return &proto.ChangeUserPasswordRsp{}, nil +}