1
This commit is contained in:
390
internal/auth/keycloak/auth.go
Normal file
390
internal/auth/keycloak/auth.go
Normal 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
|
||||
}
|
15
internal/auth/keycloak/client.go
Normal file
15
internal/auth/keycloak/client.go
Normal 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
|
||||
}
|
38
internal/auth/keycloak/constants.go
Normal file
38
internal/auth/keycloak/constants.go
Normal 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
|
||||
)
|
16
internal/auth/keycloak/errors.go
Normal file
16
internal/auth/keycloak/errors.go
Normal 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")
|
||||
)
|
51
internal/auth/keycloak/interface.go
Normal file
51
internal/auth/keycloak/interface.go
Normal 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
|
||||
}
|
||||
}
|
76
internal/auth/keycloak/jwt.go
Normal file
76
internal/auth/keycloak/jwt.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user