This commit is contained in:
Alex Shevchuk
2025-08-18 17:12:04 +03:00
commit d84487d238
157 changed files with 160686 additions and 0 deletions

View File

@@ -0,0 +1,390 @@
package keycloak
import (
"context"
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/auth"
"github.com/Nerzal/gocloak/v13"
)
func (k *Client) RegisterUser(ctx context.Context, request auth.RegisterUserRequest) (*auth.RegisterUserResponse, error) {
attrs := make(map[string][]string)
if request.User.Patronymic != nil {
attrs[PatronymicAttr] = []string{*request.User.Patronymic}
}
for key, value := range request.User.Permissions {
attrs[key] = []string{value}
}
for _, perm := range permissionsSlice {
if _, ok := attrs[perm]; !ok {
return nil, errors.Join(ErrBadRequest, ErrMissingPermission)
}
}
client, err := k.client.LoginClient(ctx, k.clientId, k.clientSecret, k.realm)
if err != nil {
return nil, k.handleError(err)
}
userGroup, err := k.getUserGroupByType(request.UserType)
if err != nil {
return nil, err
}
emailVerified := false
if k.env != "production" {
emailVerified = true
}
userId, err := k.client.CreateUser(
ctx,
client.AccessToken,
k.realm,
gocloak.User{
Email: gocloak.StringP(request.User.Email),
EmailVerified: &emailVerified,
FirstName: gocloak.StringP(request.User.FirstName),
LastName: gocloak.StringP(request.User.SecondName),
Groups: &[]string{userGroup},
Enabled: gocloak.BoolP(true),
Attributes: &attrs,
})
if err != nil {
return nil, k.handleError(err)
}
if err = k.client.SetPassword(
ctx, client.AccessToken,
userId, k.realm,
request.User.Password, false,
); err != nil {
return nil, k.handleError(err)
}
return &auth.RegisterUserResponse{
UserId: userId,
}, nil
}
func (k *Client) getUserGroupByType(userType int32) (string, error) {
switch userType {
case UserAgentType:
return UserGroupAgents, nil
case UserDistributorType:
return UserGroupDistributor, nil
default:
return "", ErrUnknownUserType
}
}
func (k *Client) LoginUser(ctx context.Context, request auth.LoginUserRequest) (*auth.LoginUserResponse, error) {
token, err := k.client.Login(ctx, k.clientId, k.clientSecret, k.realm, request.Email, request.Password)
if err != nil {
return nil, k.handleError(err)
}
userInfo, err := k.jwtManager.GetUserInfoFromToken(token.AccessToken)
if err != nil {
return nil, k.handleError(err)
}
return &auth.LoginUserResponse{
UserId: userInfo.UserId,
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}, nil
}
func (k *Client) GetNewAccessToken(ctx context.Context, request auth.GetNewAccessTokenRequest) (*auth.GetNewAccessTokenResponse, error) {
token, err := k.client.RefreshToken(ctx, request.RefreshToken, k.clientId, k.clientSecret, k.realm)
if err != nil {
return nil, k.handleError(err)
}
return &auth.GetNewAccessTokenResponse{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
}, nil
}
func (k *Client) CheckTokenIsValid(ctx context.Context, token string) (bool, error) {
resp, err := k.client.RetrospectToken(ctx, token, k.clientId, k.clientSecret, k.realm)
if err != nil {
return false, k.handleError(err)
}
if resp.Active == nil {
return false, nil
}
return *resp.Active, nil
}
func (k *Client) LogoutUser(ctx context.Context, request auth.LogoutUserRequest) error {
if err := k.client.Logout(ctx, k.clientId, k.clientSecret, k.realm, request.RefreshToken); err != nil {
return k.handleError(err)
}
if err := k.client.RevokeToken(ctx, k.realm, k.clientId, k.clientSecret, request.RefreshToken); err != nil {
return k.handleError(err)
}
return nil
}
func (k *Client) GetUserInfo(ctx context.Context, id string) (*auth.UserInfo, error) {
client, err := k.client.LoginClient(ctx, k.clientId, k.clientSecret, k.realm)
if err != nil {
return nil, k.handleError(err)
}
user, err := k.client.GetUserByID(ctx, client.AccessToken, k.realm, id)
if err != nil {
return nil, k.handleError(err)
}
if user.Attributes == nil {
return nil, ErrNoAttributes
}
groups, err := k.client.GetUserGroups(ctx, client.AccessToken, k.realm, id, gocloak.GetGroupsParams{})
if err != nil {
return nil, k.handleError(err)
}
if len(groups) == 0 {
return nil, ErrUnknownUserType
}
if groups[0].Name == nil {
return nil, ErrUnknownUserType
}
userType, err := k.getUserTypeByUserGroup(*groups[0].Name)
if err != nil {
return nil, err
}
return &auth.UserInfo{
Email: *user.Email,
FirstName: *user.FirstName,
SecondName: *user.LastName,
Patronymic: k.getUserPatronymicFromAttributes(*user.Attributes),
Permissions: k.getUserPermissionsFromAttributes(*user.Attributes),
UserType: userType,
}, nil
}
func (k *Client) getUserPatronymicFromAttributes(attr attributes) *string {
item, ok := attr[PatronymicAttr]
if !ok || len(item) == 0 {
return nil
}
return &item[0]
}
func (k *Client) getUserPermissionsFromAttributes(attr attributes) permissions {
resp := make(map[string]string)
for _, permission := range permissionsSlice {
if item, ok := attr[permission]; ok {
if len(item) != 0 {
resp[permission] = item[0]
} else {
resp[permission] = "" // todo: подумать над тем как правильно делать в таких ситуациях
}
} else {
resp[permission] = "" // todo: подумать над тем как правильно делать в таких ситуациях
}
}
return resp
}
func (k *Client) getUserTypeByUserGroup(attr string) (int32, error) {
switch attr {
case UserGroupAgents:
return UserAgentType, nil
case UserGroupDistributor:
return UserDistributorType, nil
default:
return -1, ErrUnknownUserType
}
}
func (k *Client) GetPermissionsByUsersId(ctx context.Context, id string) (*auth.GetPermissionsByUsersIdResponse, error) {
userInfo, err := k.GetUserInfo(ctx, id)
if err != nil {
return nil, err
}
return &auth.GetPermissionsByUsersIdResponse{
Balance: userInfo.Permissions[PermissionBalance],
Company: userInfo.Permissions[PermissionCompany],
Employees: userInfo.Permissions[PermissionEmployees],
Profile: userInfo.Permissions[PermissionProfile],
Submissions: userInfo.Permissions[PermissionSubmissions],
Vacancies: userInfo.Permissions[PermissionVacancies],
}, nil
}
func (k *Client) CheckPermissions(ctx context.Context, request auth.CheckPermissionsRequest) (bool, error) {
userInfo, err := k.GetUserInfo(ctx, request.UserId)
if err != nil {
return false, err
}
if userInfo.Permissions == nil {
return false, ErrInternal
}
if val, ok := userInfo.Permissions[request.RequiredPermission]; ok {
return k.getPermissionLevel(val) >= k.getPermissionLevel(request.RequiredPermissionLevel), nil
}
return false, ErrInternal
}
func (k *Client) getPermissionLevel(permission string) int8 {
switch permission {
case PermissionLevelCanEdit:
return 2
case PermissionLevelCanView:
return 1
case PermissionLevelNoPermission:
return 0
default:
return -1
}
}
func (k *Client) VerifyEmail(ctx context.Context, userID string, token string) error {
if err := k.jwtManager.VerifyEmailToken(token, userID); err != nil {
return err
}
// Get admin token
adminToken, err := k.client.LoginClient(ctx, k.clientId, k.clientSecret, k.realm)
if err != nil {
return k.handleError(err)
}
// Get user
user, err := k.client.GetUserByID(ctx, adminToken.AccessToken, k.realm, userID)
if err != nil {
return k.handleError(err)
}
// Set email as verified
user.EmailVerified = gocloak.BoolP(true)
// Update user
if err := k.client.UpdateUser(ctx, adminToken.AccessToken, k.realm, *user); err != nil {
return k.handleError(err)
}
return nil
}
func (k *Client) GetEmailVerificationStatus(ctx context.Context, userID string) (*auth.GetEmailVerificationResponse, error) {
// Get admin token
adminToken, err := k.client.LoginClient(ctx, k.clientId, k.clientSecret, k.realm)
if err != nil {
return nil, k.handleError(err)
}
// Get user
user, err := k.client.GetUserByID(ctx, adminToken.AccessToken, k.realm, userID)
if err != nil {
return nil, k.handleError(err)
}
// Check if email is verified
verified := false
if user.EmailVerified != nil {
verified = *user.EmailVerified
}
return &auth.GetEmailVerificationResponse{
EmailVerified: verified,
}, nil
}
func (k *Client) GetUserEmailVerificationToken(ctx context.Context, uid string) (*auth.GetUserTokenResponse, error) {
// Получение токена администратора
adminToken, err := k.client.LoginClient(ctx, k.clientId, k.clientSecret, k.realm)
if err != nil {
return nil, k.handleError(err)
}
// Получаем пользователя, чтобы убедиться в его существовании
_, err = k.client.GetUserByID(ctx, adminToken.AccessToken, k.realm, uid)
if err != nil {
return nil, k.handleError(err)
}
// Создаём кастомный JWT-токен для верификации email сроком на 24 часа
claims := jwt.MapClaims{
"sub": uid, // Subject (user ID)
"iat": time.Now().Unix(), // Issued at
"exp": time.Now().Add(24 * time.Hour).Unix(), // Expires in 24 hours
"purpose": "email_verification", // Custom claim to identify purpose
"iss": "molva-api-gateway", // Issuer
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Используем клиентский секрет как ключ подписи
signedToken, err := token.SignedString([]byte(k.clientSecret))
if err != nil {
return nil, ErrInternal
}
return &auth.GetUserTokenResponse{
AccessToken: signedToken,
}, nil
}
func (k *Client) ResetPassword(ctx context.Context, request auth.ResetPasswordRequest) error {
token, err := k.client.LoginClient(ctx, k.clientId, k.clientSecret, k.realm)
if err != nil {
return k.handleError(err)
}
user, err := k.client.GetUsers(
ctx,
token.AccessToken,
k.realm,
gocloak.GetUsersParams{
Username: gocloak.StringP(request.Email),
},
)
if err != nil {
return k.handleError(err)
}
if len(user) == 0 {
return ErrNotFound
}
if err := k.client.SetPassword(
ctx,
token.AccessToken,
*user[0].ID,
k.realm,
request.NewPassword,
false,
); err != nil {
return k.handleError(err)
}
return nil
}

View File

@@ -0,0 +1,15 @@
package keycloak
import (
"github.com/Nerzal/gocloak/v13"
)
type Client struct {
client *gocloak.GoCloak
jwtManager TokenManager
env string
realm string
clientId string
clientSecret string
}

View File

@@ -0,0 +1,38 @@
package keycloak
const (
PatronymicAttr = "patronymic"
PermissionLevelCanEdit = "can_edit"
PermissionLevelCanView = "can_view"
PermissionLevelNoPermission = "no_permission"
UserGroupAgents = "agents"
UserGroupDistributor = "distributors"
UserAgentType = 0
UserDistributorType = 1
PermissionBalance = "balance"
PermissionCompany = "company"
PermissionEmployees = "employees"
PermissionProfile = "profile"
PermissionSubmissions = "submissions"
PermissionVacancies = "vacancies"
)
var (
permissionsSlice = []string{
PermissionBalance,
PermissionCompany,
PermissionEmployees,
PermissionProfile,
PermissionSubmissions,
PermissionVacancies,
}
)
type (
attributes map[string][]string
permissions map[string]string
)

View File

@@ -0,0 +1,16 @@
package keycloak
import "errors"
var (
ErrInvalidToken = errors.New("invalid token")
ErrInternal = errors.New("internal error")
ErrRealmClientNotFound = errors.New("realm client not found")
ErrRealmClientUnauthorized = errors.New("realm client unauthorized")
ErrUnknownUserType = errors.New("unknown user type")
ErrNoAttributes = errors.New("no attributes")
ErrAlreadyExists = errors.New("user already exists")
ErrBadRequest = errors.New("bad request")
ErrNotFound = errors.New("user not found")
ErrMissingPermission = errors.New("missing one or more permissions")
)

View File

@@ -0,0 +1,51 @@
package keycloak
import (
"errors"
"net/http"
"github.com/Nerzal/gocloak/v13"
)
type Config struct {
Env string
AuthServerAddr string
Realm string
ClientId string
ClientSecret string
}
func New(c *Config) (*Client, error) {
cli := &Client{
client: gocloak.NewClient(c.AuthServerAddr),
jwtManager: NewJWTManager(),
env: c.Env,
realm: c.Realm,
clientId: c.ClientId,
clientSecret: c.ClientSecret,
}
return cli, nil
}
func (k *Client) handleError(err error) error {
var apiErr *gocloak.APIError
if !errors.As(err, &apiErr) {
return ErrInternal
}
switch apiErr.Code {
case http.StatusNotFound:
return ErrRealmClientNotFound
case http.StatusUnauthorized:
return ErrRealmClientUnauthorized
case http.StatusConflict:
return ErrAlreadyExists
case http.StatusBadRequest:
return ErrBadRequest
default:
return ErrInternal
}
}

View File

@@ -0,0 +1,76 @@
package keycloak
import (
"git-molva.ru/Molva/molva-backend/services/api_gateway/internal/auth"
"github.com/golang-jwt/jwt/v5"
"time"
)
type TokenManager interface {
GetUserInfoFromToken(token string) (*auth.UserInfoFromToken, error)
VerifyEmailToken(token string, expectedUserID string) error
}
type tokenManager struct {
parser *jwt.Parser
}
func NewJWTManager() TokenManager {
return &tokenManager{
parser: jwt.NewParser(),
}
}
func (t *tokenManager) GetUserInfoFromToken(token string) (*auth.UserInfoFromToken, error) {
userToken, _, err := t.parser.ParseUnverified(token, jwt.MapClaims{})
if err != nil {
return nil, ErrInvalidToken
}
if claims, ok := userToken.Claims.(jwt.MapClaims); ok {
resp := auth.UserInfoFromToken{}
resp.UserId, ok = claims["sub"].(string)
if !ok {
return nil, ErrInvalidToken
}
return &resp, nil
}
return nil, ErrInvalidToken
}
func (t *tokenManager) VerifyEmailToken(token string, expectedUserID string) error {
// Parse token without verification (we'll verify signature manually)
parsedToken, _, err := t.parser.ParseUnverified(token, jwt.MapClaims{})
if err != nil {
return ErrInvalidToken
}
// Extract claims
claims, ok := parsedToken.Claims.(jwt.MapClaims)
if !ok {
return ErrInvalidToken
}
// Check if token is for email verification
purpose, ok := claims["purpose"].(string)
if !ok || purpose != "email_verification" {
return ErrInvalidToken
}
// Check if subject matches expected user ID
sub, ok := claims["sub"].(string)
if !ok || sub != expectedUserID {
return ErrInvalidToken
}
// Check expiration
exp, ok := claims["exp"].(float64)
if !ok || float64(time.Now().Unix()) > exp {
return ErrInvalidToken
}
return nil
}