add change password

This commit is contained in:
2026-09-09 18:53:08 +07:00
parent 4feece62c8
commit 0fb4a6f37b
10 changed files with 1161 additions and 598 deletions
+148
View File
@@ -0,0 +1,148 @@
package app
import (
"context"
"errors"
"evening_detective_server/internal/modules/processor_jwt"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/users_repo"
"evening_detective_server/internal/services/users_service"
"evening_detective_server/proto"
"fmt"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"golang.org/x/crypto/bcrypt"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
"net"
"net/http/httptest"
"strings"
"testing"
)
func TestChangeUserPasswordAccess(t *testing.T) {
s := &server{}
for _, tc := range []struct {
ctx context.Context
want codes.Code
}{
{context.Background(), codes.Unauthenticated},
{ctxWithClaims(), codes.PermissionDenied},
{ctxWithClaims("user"), codes.PermissionDenied},
{ctxWithClaims("author"), codes.PermissionDenied},
{ctxWithClaims("organizer"), codes.PermissionDenied},
} {
_, err := s.ChangeUserPassword(tc.ctx, &proto.ChangeUserPasswordReq{Id: 2, NewPassword: "123456789012"})
if status.Code(err) != tc.want {
t.Errorf("code = %v, want %v", status.Code(err), tc.want)
}
}
}
// Embed the real repository so unexpected calls fail rather than silently pass.
type passwordRepo struct {
*users_repo.UsersRepo
role string
saveErr error
hash string
}
func (r *passwordRepo) GetUserByID(_ context.Context, id int) (*repos.User, error) {
if id == 1 {
return &repos.User{ID: 1, Roles: []string{r.role}}, nil
}
if id == 2 {
return &repos.User{ID: 2, Email: "target@example.org"}, nil
}
return nil, users_repo.ErrUserNotFound
}
func (r *passwordRepo) UpdateUserPassword(_ context.Context, email, hash string) error {
if r.saveErr != nil {
return r.saveErr
}
r.hash = hash
return nil
}
func TestChangeUserPasswordTransports(t *testing.T) {
ctx := context.Background()
jwt := processor_jwt.NewProcessor("test-secret")
repo := &passwordRepo{role: "admin"}
svc := users_service.NewUsersService(repo, nil, nil, nil, nil, "")
listener := bufconn.Listen(1024 * 1024)
defer listener.Close()
gs := grpc.NewServer(grpc.UnaryInterceptor(processor_jwt.NewAuthorizationInterceptor(nil, jwt)))
proto.RegisterEveningDetectiveServerServer(gs, &server{usersService: svc})
go gs.Serve(listener)
defer gs.Stop()
conn, err := grpc.NewClient("passthrough:///test", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }))
if err != nil {
t.Fatal(err)
}
defer conn.Close()
mux := runtime.NewServeMux()
if err := proto.RegisterEveningDetectiveServerHandler(ctx, mux, conn); err != nil {
t.Fatal(err)
}
client := proto.NewEveningDetectiveServerClient(conn)
for _, tc := range []struct {
name, tokenRole, dbRole string
id int32
password string
saveErr error
code codes.Code
httpCode int
}{
{"anonymous", "", "admin", 2, "123456789012", nil, codes.Unauthenticated, 401},
{"user", "user", "admin", 2, "123456789012", nil, codes.PermissionDenied, 403},
{"removed admin", "admin", "user", 2, "123456789012", nil, codes.PermissionDenied, 403},
{"invalid", "admin", "admin", 2, "short", nil, codes.InvalidArgument, 400},
{"missing", "admin", "admin", 3, "123456789012", nil, codes.NotFound, 404},
{"save failure", "admin", "admin", 2, "123456789012", errors.New("private database detail"), codes.Internal, 500},
{"deleted during save", "admin", "admin", 2, "123456789012", users_repo.ErrUserNotFound, codes.NotFound, 404},
{"success", "admin", "admin", 2, "123456789012", nil, codes.OK, 200},
} {
t.Run(tc.name, func(t *testing.T) {
repo.role, repo.saveErr, repo.hash = tc.dbRole, tc.saveErr, ""
token := ""
rpcCtx := ctx
if tc.tokenRole != "" {
token, err = jwt.GenerateAccessToken(1, "admin@example.org", []string{tc.tokenRole})
if err != nil {
t.Fatal(err)
}
rpcCtx = metadata.NewOutgoingContext(ctx, metadata.Pairs("authorization", "Bearer "+token))
}
_, err := client.ChangeUserPassword(rpcCtx, &proto.ChangeUserPasswordReq{Id: tc.id, NewPassword: tc.password})
if status.Code(err) != tc.code {
t.Fatalf("gRPC = %v, want %v", err, tc.code)
}
req := httptest.NewRequest("POST", fmt.Sprintf("/api/users/%d/password", tc.id), strings.NewReader(fmt.Sprintf(`{"new_password":%q}`, tc.password)))
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rsp := httptest.NewRecorder()
mux.ServeHTTP(rsp, req)
if rsp.Code != tc.httpCode {
t.Fatalf("HTTP = %d: %s", rsp.Code, rsp.Body.String())
}
if strings.Contains(rsp.Body.String(), tc.password) || strings.Contains(rsp.Body.String(), "private database detail") {
t.Fatal("sensitive response")
}
if tc.code == codes.OK {
if bcrypt.CompareHashAndPassword([]byte(repo.hash), []byte(tc.password)) != nil {
t.Fatal("password not saved")
}
if _, err := jwt.GetClaims(token); err != nil {
t.Fatal("existing token rejected")
}
} else if repo.hash != "" {
t.Fatal("unauthorized or failed write")
}
})
}
}
+23
View File
@@ -14,6 +14,7 @@ import (
"evening_detective_server/internal/modules/scenario_archive"
"evening_detective_server/internal/modules/string_tools"
"evening_detective_server/internal/repos/scenarios_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"
@@ -1226,3 +1227,25 @@ 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
}
@@ -0,0 +1,63 @@
package users_service
import (
"context"
"errors"
"strings"
"testing"
"evening_detective_server/internal/repos"
"evening_detective_server/internal/repos/users_repo"
"golang.org/x/crypto/bcrypt"
)
func TestChangeUserPassword(t *testing.T) {
for _, tc := range []struct {
name, role, password string
actor, target int
want error
}{
{"success", "admin", " новый пароль ", 1, 2, nil},
{"other admin", "admin", "123456789012", 1, 3, nil},
{"removed role", "user", "123456789012", 1, 2, ErrPasswordChangeForbidden},
{"missing actor", "admin", "123456789012", 4, 2, ErrPasswordChangeForbidden},
{"self", "admin", "123456789012", 1, 1, ErrInvalidPasswordChange},
{"bad id", "admin", "123456789012", 1, -1, ErrInvalidPasswordChange},
{"missing target", "admin", "123456789012", 1, 4, users_repo.ErrUserNotFound},
{"empty", "admin", "", 1, 2, ErrInvalidPasswordChange},
{"short unicode", "admin", "яяббввггдде", 1, 2, ErrInvalidPasswordChange},
{"too many bytes", "admin", strings.Repeat("я", 37), 1, 2, ErrInvalidPasswordChange},
{"72 bytes", "admin", strings.Repeat("я", 36), 1, 2, nil},
} {
t.Run(tc.name, func(t *testing.T) {
r := newFakeRepo()
old, err := bcrypt.GenerateFromPassword([]byte("old password"), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
for id, role := range []string{tc.role, "user", "admin"} {
u := &repos.User{ID: id + 1, Email: role + string(rune('0'+id)), Roles: []string{role}, PasswordHash: string(old)}
r.byID[u.ID] = u
r.users[u.Email] = u
}
// Nil JWT, token repository and mail sender ensure this operation does not use them.
s := &UsersService{usersRepo: r}
err = s.ChangeUserPassword(context.Background(), tc.actor, tc.target, tc.password)
if !errors.Is(err, tc.want) {
t.Fatalf("error = %v, want %v", err, tc.want)
}
for id, u := range r.byID {
if tc.want == nil && id == tc.target {
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(tc.password)) != nil {
t.Fatal("new password rejected")
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte("old password")) == nil {
t.Fatal("old password accepted")
}
} else if u.PasswordHash != string(old) {
t.Fatal("unrelated or rejected update")
}
}
})
}
}
@@ -17,6 +17,7 @@ import (
"slices"
"strings"
"time"
"unicode/utf8"
"golang.org/x/crypto/bcrypt"
)
@@ -372,3 +373,34 @@ func (s *UsersService) DeleteUserRole(
}
return s.usersRepo.DeleteUserRole(ctx, userId, role)
}
var (
ErrPasswordChangeForbidden = errors.New("Изменять чужой пароль может только администратор")
ErrInvalidPasswordChange = errors.New("Укажите чужой положительный ID и пароль от 12 символов до 72 байт")
)
// ChangeUserPassword сохраняет только хеш; действующие сеансы не изменяются.
func (s *UsersService) ChangeUserPassword(ctx context.Context, actorID, userID int, password string) error {
actor, err := s.usersRepo.GetUserByID(ctx, actorID)
if errors.Is(err, users_repo.ErrUserNotFound) {
return ErrPasswordChangeForbidden
}
if err != nil {
return err
}
if !slices.Contains(actor.Roles, roles.Admin) {
return ErrPasswordChangeForbidden
}
if userID <= 0 || userID == actorID || !utf8.ValidString(password) || utf8.RuneCountInString(password) < 12 || len(password) > 72 {
return ErrInvalidPasswordChange
}
user, err := s.usersRepo.GetUserByID(ctx, userID)
if err != nil {
return err
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
return s.usersRepo.UpdateUserPassword(ctx, user.Email, string(hash))
}