first
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/modules/users"
|
||||
"backend/internal/platform/middleware"
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
const (
|
||||
adminRefreshCookieName = "admin_refresh_token"
|
||||
adminRefreshCookiePath = "/api/auth/admin"
|
||||
)
|
||||
|
||||
// AdminHandler exposes the admin-space auth endpoints. It is kept entirely
|
||||
// separate from CustomerHandler: different cookie name, different cookie
|
||||
// path, different JWT audience, so an admin session can never be replayed
|
||||
// against a future customer-facing endpoint.
|
||||
type AdminHandler struct {
|
||||
service *Service
|
||||
cookieSecure bool
|
||||
}
|
||||
|
||||
func NewAdminHandler(service *Service, cookieSecure bool) *AdminHandler {
|
||||
return &AdminHandler{service: service, cookieSecure: cookieSecure}
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type authUserResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
type accessTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
User *authUserResponse `json:"user,omitempty"`
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Login(c *gin.Context) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
pair, user, err := h.service.Login(c.Request.Context(), security.AudienceAdmin, users.RoleAdmin, req.Email, req.Password)
|
||||
if err != nil {
|
||||
h.respondLoginError(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.setRefreshCookie(c, pair.RefreshToken)
|
||||
c.JSON(http.StatusOK, accessTokenResponse{
|
||||
AccessToken: pair.AccessToken,
|
||||
User: &authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Refresh(c *gin.Context) {
|
||||
token, err := c.Cookie(adminRefreshCookieName)
|
||||
if err != nil || token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
pair, err := h.service.Refresh(c.Request.Context(), security.AudienceAdmin, token)
|
||||
if err != nil {
|
||||
h.clearRefreshCookie(c)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
h.setRefreshCookie(c, pair.RefreshToken)
|
||||
c.JSON(http.StatusOK, accessTokenResponse{AccessToken: pair.AccessToken})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Logout(c *gin.Context) {
|
||||
if token, err := c.Cookie(adminRefreshCookieName); err == nil && token != "" {
|
||||
_ = h.service.Logout(c.Request.Context(), security.AudienceAdmin, token)
|
||||
}
|
||||
h.clearRefreshCookie(c)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Me(c *gin.Context) {
|
||||
userID, ok := middleware.GetUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
user, err := h.service.Me(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, users.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) respondLoginError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidCredentials), errors.Is(err, ErrAccountDisabled):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) setRefreshCookie(c *gin.Context, token string) {
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(adminRefreshCookieName, token, int(h.service.RefreshTTL().Seconds()), adminRefreshCookiePath, "", h.cookieSecure, true)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) clearRefreshCookie(c *gin.Context) {
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(adminRefreshCookieName, "", -1, adminRefreshCookiePath, "", h.cookieSecure, true)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package auth
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// RegisterAdminRoutes mounts the admin-space auth endpoints. loginRateLimit
|
||||
// is applied only to /login to blunt credential-stuffing/brute-force
|
||||
// attempts without throttling normal authenticated traffic.
|
||||
func RegisterAdminRoutes(rg *gin.RouterGroup, h *AdminHandler, requireAdmin gin.HandlerFunc, loginRateLimit gin.HandlerFunc) {
|
||||
group := rg.Group("/auth/admin")
|
||||
group.POST("/login", loginRateLimit, h.Login)
|
||||
group.POST("/refresh", h.Refresh)
|
||||
group.POST("/logout", h.Logout)
|
||||
group.GET("/me", requireAdmin, h.Me)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"backend/internal/modules/users"
|
||||
"backend/internal/platform/middleware"
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
// customer cookie name/path are intentionally distinct from the admin ones
|
||||
// (see admin_handler.go) so the two spaces never share a session cookie.
|
||||
const (
|
||||
customerRefreshCookieName = "customer_refresh_token"
|
||||
customerRefreshCookiePath = "/api/auth/customer"
|
||||
)
|
||||
|
||||
// CustomerHandler mirrors AdminHandler but issues/accepts only
|
||||
// customer-audience tokens. Not mounted by main.go in this phase (no
|
||||
// customer-facing module needs it yet) — it exists so the future
|
||||
// cart/orders module can activate customer auth without touching the admin
|
||||
// code path at all.
|
||||
type CustomerHandler struct {
|
||||
service *Service
|
||||
cookieSecure bool
|
||||
}
|
||||
|
||||
func NewCustomerHandler(service *Service, cookieSecure bool) *CustomerHandler {
|
||||
return &CustomerHandler{service: service, cookieSecure: cookieSecure}
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) Login(c *gin.Context) {
|
||||
var req loginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "validation error", "details": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
pair, user, err := h.service.Login(c.Request.Context(), security.AudienceCustomer, users.RoleCustomer, req.Email, req.Password)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidCredentials), errors.Is(err, ErrAccountDisabled):
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "login failed"})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
h.setRefreshCookie(c, pair.RefreshToken)
|
||||
c.JSON(http.StatusOK, accessTokenResponse{
|
||||
AccessToken: pair.AccessToken,
|
||||
User: &authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) Refresh(c *gin.Context) {
|
||||
token, err := c.Cookie(customerRefreshCookieName)
|
||||
if err != nil || token == "" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
pair, err := h.service.Refresh(c.Request.Context(), security.AudienceCustomer, token)
|
||||
if err != nil {
|
||||
h.clearRefreshCookie(c)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired refresh token"})
|
||||
return
|
||||
}
|
||||
|
||||
h.setRefreshCookie(c, pair.RefreshToken)
|
||||
c.JSON(http.StatusOK, accessTokenResponse{AccessToken: pair.AccessToken})
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) Logout(c *gin.Context) {
|
||||
if token, err := c.Cookie(customerRefreshCookieName); err == nil && token != "" {
|
||||
_ = h.service.Logout(c.Request.Context(), security.AudienceCustomer, token)
|
||||
}
|
||||
h.clearRefreshCookie(c)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) Me(c *gin.Context) {
|
||||
userID, ok := middleware.GetUserID(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
user, err := h.service.Me(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, users.ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, authUserResponse{ID: user.ID, Email: user.Email, Role: user.Role})
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) setRefreshCookie(c *gin.Context, token string) {
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(customerRefreshCookieName, token, int(h.service.RefreshTTL().Seconds()), customerRefreshCookiePath, "", h.cookieSecure, true)
|
||||
}
|
||||
|
||||
func (h *CustomerHandler) clearRefreshCookie(c *gin.Context) {
|
||||
c.SetSameSite(http.SameSiteStrictMode)
|
||||
c.SetCookie(customerRefreshCookieName, "", -1, customerRefreshCookiePath, "", h.cookieSecure, true)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package auth
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
// RegisterCustomerRoutes mounts the customer-space auth endpoints. Deliberately
|
||||
// NOT called from cmd/api/main.go in this phase: there is no customer-facing
|
||||
// module (cart/orders) yet, so exposing these routes today would just be a
|
||||
// dead surface. Wiring is ready for when that module is activated.
|
||||
func RegisterCustomerRoutes(rg *gin.RouterGroup, h *CustomerHandler, requireCustomer gin.HandlerFunc, loginRateLimit gin.HandlerFunc) {
|
||||
group := rg.Group("/auth/customer")
|
||||
group.POST("/login", loginRateLimit, h.Login)
|
||||
group.POST("/refresh", h.Refresh)
|
||||
group.POST("/logout", h.Logout)
|
||||
group.GET("/me", requireCustomer, h.Me)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Package auth issues and verifies sessions (access + refresh tokens) for
|
||||
// both the admin space and the (not-yet-mounted) customer space. It has no
|
||||
// database table of its own: user identity/credentials live in the users
|
||||
// module, and refresh-token/session state lives entirely in Redis.
|
||||
package auth
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrInvalidRefreshToken = errors.New("invalid refresh token")
|
||||
ErrAccountDisabled = errors.New("account disabled")
|
||||
)
|
||||
|
||||
// TokenPair is returned to the client on login/refresh: the access token is
|
||||
// meant to be kept in memory by the frontend, the refresh token is meant to
|
||||
// be set as an httpOnly cookie by the handler (never returned to JS).
|
||||
type TokenPair struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
// RefreshStore tracks live refresh tokens in Redis, scoped per audience
|
||||
// (admin/customer) so a session in one space is never visible to the other.
|
||||
type RefreshStore interface {
|
||||
Issue(ctx context.Context, aud security.Audience, userID uuid.UUID, ttl time.Duration) (string, error)
|
||||
Rotate(ctx context.Context, aud security.Audience, oldToken string, ttl time.Duration) (newToken string, userID uuid.UUID, err error)
|
||||
Revoke(ctx context.Context, aud security.Audience, token string) error
|
||||
RevokeAllForUser(ctx context.Context, aud security.Audience, userID uuid.UUID) error
|
||||
}
|
||||
|
||||
type redisRefreshStore struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func NewRefreshStore(client *redis.Client) RefreshStore {
|
||||
return &redisRefreshStore{client: client}
|
||||
}
|
||||
|
||||
func sessionKey(aud security.Audience, token string) string {
|
||||
return fmt.Sprintf("session:%s:%s", aud, token)
|
||||
}
|
||||
|
||||
func userSetKey(aud security.Audience, userID uuid.UUID) string {
|
||||
return fmt.Sprintf("session:%s:user:%s", aud, userID)
|
||||
}
|
||||
|
||||
func randomToken() (string, error) {
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
||||
}
|
||||
|
||||
func (s *redisRefreshStore) Issue(ctx context.Context, aud security.Audience, userID uuid.UUID, ttl time.Duration) (string, error) {
|
||||
token, err := randomToken()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate refresh token: %w", err)
|
||||
}
|
||||
if err := s.client.Set(ctx, sessionKey(aud, token), userID.String(), ttl).Err(); err != nil {
|
||||
return "", fmt.Errorf("store refresh token: %w", err)
|
||||
}
|
||||
// Best-effort secondary index for RevokeAllForUser; a token still present
|
||||
// here after its primary key has naturally expired is harmless (RevokeAllForUser
|
||||
// simply issues a DEL for an already-gone key).
|
||||
s.client.SAdd(ctx, userSetKey(aud, userID), token)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *redisRefreshStore) Rotate(ctx context.Context, aud security.Audience, oldToken string, ttl time.Duration) (string, uuid.UUID, error) {
|
||||
val, err := s.client.GetDel(ctx, sessionKey(aud, oldToken)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return "", uuid.UUID{}, ErrInvalidRefreshToken
|
||||
}
|
||||
if err != nil {
|
||||
return "", uuid.UUID{}, fmt.Errorf("lookup refresh token: %w", err)
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(val)
|
||||
if err != nil {
|
||||
return "", uuid.UUID{}, fmt.Errorf("corrupt session value: %w", err)
|
||||
}
|
||||
s.client.SRem(ctx, userSetKey(aud, userID), oldToken)
|
||||
|
||||
newToken, err := s.Issue(ctx, aud, userID, ttl)
|
||||
if err != nil {
|
||||
return "", uuid.UUID{}, err
|
||||
}
|
||||
return newToken, userID, nil
|
||||
}
|
||||
|
||||
func (s *redisRefreshStore) Revoke(ctx context.Context, aud security.Audience, token string) error {
|
||||
val, err := s.client.GetDel(ctx, sessionKey(aud, token)).Result()
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return nil // already revoked/expired: logout is idempotent
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("revoke refresh token: %w", err)
|
||||
}
|
||||
if userID, parseErr := uuid.Parse(val); parseErr == nil {
|
||||
s.client.SRem(ctx, userSetKey(aud, userID), token)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *redisRefreshStore) RevokeAllForUser(ctx context.Context, aud security.Audience, userID uuid.UUID) error {
|
||||
setKey := userSetKey(aud, userID)
|
||||
tokens, err := s.client.SMembers(ctx, setKey).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("list sessions: %w", err)
|
||||
}
|
||||
if len(tokens) > 0 {
|
||||
keys := make([]string, len(tokens))
|
||||
for i, t := range tokens {
|
||||
keys[i] = sessionKey(aud, t)
|
||||
}
|
||||
if err := s.client.Del(ctx, keys...).Err(); err != nil {
|
||||
return fmt.Errorf("revoke sessions: %w", err)
|
||||
}
|
||||
}
|
||||
s.client.Del(ctx, setKey)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"backend/internal/modules/users"
|
||||
"backend/internal/platform/security"
|
||||
)
|
||||
|
||||
// UserFinder is the minimal slice of the users module this service needs.
|
||||
// Defined here (consumer side) rather than depending on the full
|
||||
// users.Repository, so auth only ever reads user records, never writes them.
|
||||
type UserFinder interface {
|
||||
FindByEmail(ctx context.Context, email string) (*users.User, error)
|
||||
FindByID(ctx context.Context, id uuid.UUID) (*users.User, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
users UserFinder
|
||||
refresh RefreshStore
|
||||
jwtSecret string
|
||||
accessTTL time.Duration
|
||||
refreshTTL time.Duration
|
||||
}
|
||||
|
||||
func NewService(userFinder UserFinder, refresh RefreshStore, jwtSecret string, accessTTL, refreshTTL time.Duration) *Service {
|
||||
return &Service{
|
||||
users: userFinder,
|
||||
refresh: refresh,
|
||||
jwtSecret: jwtSecret,
|
||||
accessTTL: accessTTL,
|
||||
refreshTTL: refreshTTL,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) RefreshTTL() time.Duration { return s.refreshTTL }
|
||||
|
||||
// Login authenticates a user for a specific space (aud) and enforces that
|
||||
// the account's role matches that space (an admin account cannot log into
|
||||
// the customer space and vice versa, even before the customer module ships).
|
||||
func (s *Service) Login(ctx context.Context, aud security.Audience, expectedRole, email, password string) (*TokenPair, *users.User, error) {
|
||||
user, err := s.users.FindByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, users.ErrNotFound) {
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
return nil, nil, ErrAccountDisabled
|
||||
}
|
||||
if user.Role != expectedRole {
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
ok, err := security.VerifyPassword(user.PasswordHash, password)
|
||||
if err != nil || !ok {
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
pair, err := s.issuePair(ctx, aud, user)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return pair, user, nil
|
||||
}
|
||||
|
||||
// Refresh rotates a refresh token (single use) and issues a fresh pair. If
|
||||
// the presented token is unknown to the store (already used, revoked, or
|
||||
// expired) it is treated as invalid; genuine reuse of a stolen token simply
|
||||
// fails from that point on since the old token was deleted at issuance time.
|
||||
func (s *Service) Refresh(ctx context.Context, aud security.Audience, refreshToken string) (*TokenPair, error) {
|
||||
newRefreshToken, userID, err := s.refresh.Rotate(ctx, aud, refreshToken, s.refreshTTL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := s.users.FindByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup user for refresh: %w", err)
|
||||
}
|
||||
if !user.IsActive {
|
||||
_ = s.refresh.RevokeAllForUser(ctx, aud, userID)
|
||||
return nil, ErrAccountDisabled
|
||||
}
|
||||
|
||||
access, err := security.IssueAccessToken(s.jwtSecret, s.accessTTL, user.ID, user.Role, aud)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue access token: %w", err)
|
||||
}
|
||||
|
||||
return &TokenPair{AccessToken: access, RefreshToken: newRefreshToken}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Logout(ctx context.Context, aud security.Audience, refreshToken string) error {
|
||||
return s.refresh.Revoke(ctx, aud, refreshToken)
|
||||
}
|
||||
|
||||
func (s *Service) LogoutAll(ctx context.Context, aud security.Audience, userID uuid.UUID) error {
|
||||
return s.refresh.RevokeAllForUser(ctx, aud, userID)
|
||||
}
|
||||
|
||||
func (s *Service) Me(ctx context.Context, userID uuid.UUID) (*users.User, error) {
|
||||
return s.users.FindByID(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) issuePair(ctx context.Context, aud security.Audience, user *users.User) (*TokenPair, error) {
|
||||
access, err := security.IssueAccessToken(s.jwtSecret, s.accessTTL, user.ID, user.Role, aud)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue access token: %w", err)
|
||||
}
|
||||
refreshToken, err := s.refresh.Issue(ctx, aud, user.ID, s.refreshTTL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue refresh token: %w", err)
|
||||
}
|
||||
return &TokenPair{AccessToken: access, RefreshToken: refreshToken}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user