refresh token fix
This commit is contained in:
parent
886b62ed68
commit
ce1b827768
|
|
@ -414,7 +414,7 @@ func main() {
|
|||
)
|
||||
|
||||
// Team management service
|
||||
teamSvc := team.NewService(repository.NewTeamStore(store))
|
||||
teamSvc := team.NewService(repository.NewTeamStore(store), cfg.RefreshExpiry)
|
||||
|
||||
// santimpayClient := santimpay.NewSantimPayClient(cfg)
|
||||
|
||||
|
|
|
|||
2
db/migrations/000039_team_refresh_tokens.down.sql
Normal file
2
db/migrations/000039_team_refresh_tokens.down.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
DROP INDEX IF EXISTS idx_team_refresh_tokens_team_member_id;
|
||||
DROP TABLE IF EXISTS team_refresh_tokens;
|
||||
11
db/migrations/000039_team_refresh_tokens.up.sql
Normal file
11
db/migrations/000039_team_refresh_tokens.up.sql
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
CREATE TABLE IF NOT EXISTS team_refresh_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
team_member_id BIGINT NOT NULL REFERENCES team_members(id) ON DELETE CASCADE,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_team_refresh_tokens_team_member_id
|
||||
ON team_refresh_tokens (team_member_id);
|
||||
19
db/query/team_refresh_tokens.sql
Normal file
19
db/query/team_refresh_tokens.sql
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
-- name: RevokeAllActiveTeamRefreshTokensForMember :exec
|
||||
UPDATE team_refresh_tokens
|
||||
SET revoked = TRUE
|
||||
WHERE team_member_id = $1
|
||||
AND revoked = FALSE;
|
||||
|
||||
-- name: CreateTeamRefreshToken :exec
|
||||
INSERT INTO team_refresh_tokens (team_member_id, token, expires_at, revoked, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5);
|
||||
|
||||
-- name: GetTeamRefreshTokenByToken :one
|
||||
SELECT *
|
||||
FROM team_refresh_tokens
|
||||
WHERE token = $1;
|
||||
|
||||
-- name: RevokeTeamRefreshTokenByToken :exec
|
||||
UPDATE team_refresh_tokens
|
||||
SET revoked = TRUE
|
||||
WHERE token = $1;
|
||||
77
docs/docs.go
77
docs/docs.go
|
|
@ -7682,6 +7682,70 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/team/refresh": {
|
||||
"post": {
|
||||
"description": "Exchanges a valid team refresh token for a new access JWT and a rotated refresh token (use only tokens from POST /team/login or this endpoint).",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"team"
|
||||
],
|
||||
"summary": "Refresh team member tokens",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Current refresh token",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.teamMemberRefreshReq"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/handlers.teamMemberLoginRes"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/team/stats": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
|
@ -11609,7 +11673,7 @@ const docTemplate = `{
|
|||
},
|
||||
"refresh_token": {
|
||||
"type": "string",
|
||||
"example": ""
|
||||
"example": "\u003copaque-refresh-token\u003e"
|
||||
},
|
||||
"team_role": {
|
||||
"type": "string",
|
||||
|
|
@ -11617,6 +11681,17 @@ const docTemplate = `{
|
|||
}
|
||||
}
|
||||
},
|
||||
"handlers.teamMemberRefreshReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"refresh_token"
|
||||
],
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.updateAdminReq": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -7674,6 +7674,70 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/team/refresh": {
|
||||
"post": {
|
||||
"description": "Exchanges a valid team refresh token for a new access JWT and a rotated refresh token (use only tokens from POST /team/login or this endpoint).",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"team"
|
||||
],
|
||||
"summary": "Refresh team member tokens",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Current refresh token",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.teamMemberRefreshReq"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/domain.Response"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/definitions/handlers.teamMemberLoginRes"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/domain.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/team/stats": {
|
||||
"get": {
|
||||
"security": [
|
||||
|
|
@ -11601,7 +11665,7 @@
|
|||
},
|
||||
"refresh_token": {
|
||||
"type": "string",
|
||||
"example": ""
|
||||
"example": "\u003copaque-refresh-token\u003e"
|
||||
},
|
||||
"team_role": {
|
||||
"type": "string",
|
||||
|
|
@ -11609,6 +11673,17 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"handlers.teamMemberRefreshReq": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"refresh_token"
|
||||
],
|
||||
"properties": {
|
||||
"refresh_token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.updateAdminReq": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
|
|||
|
|
@ -1448,12 +1448,19 @@ definitions:
|
|||
example: 1
|
||||
type: integer
|
||||
refresh_token:
|
||||
example: ""
|
||||
example: <opaque-refresh-token>
|
||||
type: string
|
||||
team_role:
|
||||
example: admin
|
||||
type: string
|
||||
type: object
|
||||
handlers.teamMemberRefreshReq:
|
||||
properties:
|
||||
refresh_token:
|
||||
type: string
|
||||
required:
|
||||
- refresh_token
|
||||
type: object
|
||||
handlers.updateAdminReq:
|
||||
properties:
|
||||
first_name:
|
||||
|
|
@ -7117,6 +7124,46 @@ paths:
|
|||
summary: Update team member status
|
||||
tags:
|
||||
- team
|
||||
/api/v1/team/refresh:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Exchanges a valid team refresh token for a new access JWT and a
|
||||
rotated refresh token (use only tokens from POST /team/login or this endpoint).
|
||||
parameters:
|
||||
- description: Current refresh token
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.teamMemberRefreshReq'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/definitions/domain.Response'
|
||||
- properties:
|
||||
data:
|
||||
$ref: '#/definitions/handlers.teamMemberLoginRes'
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/domain.ErrorResponse'
|
||||
summary: Refresh team member tokens
|
||||
tags:
|
||||
- team
|
||||
/api/v1/team/stats:
|
||||
get:
|
||||
consumes:
|
||||
|
|
|
|||
|
|
@ -477,6 +477,15 @@ type TeamMember struct {
|
|||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type TeamRefreshToken struct {
|
||||
ID int64 `json:"id"`
|
||||
TeamMemberID int64 `json:"team_member_id"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
Revoked bool `json:"revoked"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
FirstName pgtype.Text `json:"first_name"`
|
||||
|
|
|
|||
79
gen/db/team_refresh_tokens.sql.go
Normal file
79
gen/db/team_refresh_tokens.sql.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.30.0
|
||||
// source: team_refresh_tokens.sql
|
||||
|
||||
package dbgen
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
const CreateTeamRefreshToken = `-- name: CreateTeamRefreshToken :exec
|
||||
INSERT INTO team_refresh_tokens (team_member_id, token, expires_at, revoked, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`
|
||||
|
||||
type CreateTeamRefreshTokenParams struct {
|
||||
TeamMemberID int64 `json:"team_member_id"`
|
||||
Token string `json:"token"`
|
||||
ExpiresAt pgtype.Timestamptz `json:"expires_at"`
|
||||
Revoked bool `json:"revoked"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) CreateTeamRefreshToken(ctx context.Context, arg CreateTeamRefreshTokenParams) error {
|
||||
_, err := q.db.Exec(ctx, CreateTeamRefreshToken,
|
||||
arg.TeamMemberID,
|
||||
arg.Token,
|
||||
arg.ExpiresAt,
|
||||
arg.Revoked,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const GetTeamRefreshTokenByToken = `-- name: GetTeamRefreshTokenByToken :one
|
||||
SELECT id, team_member_id, token, expires_at, revoked, created_at
|
||||
FROM team_refresh_tokens
|
||||
WHERE token = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetTeamRefreshTokenByToken(ctx context.Context, token string) (TeamRefreshToken, error) {
|
||||
row := q.db.QueryRow(ctx, GetTeamRefreshTokenByToken, token)
|
||||
var i TeamRefreshToken
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.TeamMemberID,
|
||||
&i.Token,
|
||||
&i.ExpiresAt,
|
||||
&i.Revoked,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const RevokeAllActiveTeamRefreshTokensForMember = `-- name: RevokeAllActiveTeamRefreshTokensForMember :exec
|
||||
UPDATE team_refresh_tokens
|
||||
SET revoked = TRUE
|
||||
WHERE team_member_id = $1
|
||||
AND revoked = FALSE
|
||||
`
|
||||
|
||||
func (q *Queries) RevokeAllActiveTeamRefreshTokensForMember(ctx context.Context, teamMemberID int64) error {
|
||||
_, err := q.db.Exec(ctx, RevokeAllActiveTeamRefreshTokensForMember, teamMemberID)
|
||||
return err
|
||||
}
|
||||
|
||||
const RevokeTeamRefreshTokenByToken = `-- name: RevokeTeamRefreshTokenByToken :exec
|
||||
UPDATE team_refresh_tokens
|
||||
SET revoked = TRUE
|
||||
WHERE token = $1
|
||||
`
|
||||
|
||||
func (q *Queries) RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error {
|
||||
_, err := q.db.Exec(ctx, RevokeTeamRefreshTokenByToken, token)
|
||||
return err
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ var (
|
|||
ErrInvalidTeamMemberStatus = errors.New("invalid team member status")
|
||||
ErrInvalidEmploymentType = errors.New("invalid employment type")
|
||||
ErrTeamMemberEmailNotVerified = errors.New("team member email not verified")
|
||||
ErrTeamRefreshTokenNotFound = errors.New("team refresh token not found")
|
||||
ErrTeamRefreshTokenExpired = errors.New("team refresh token expired")
|
||||
)
|
||||
|
||||
type TeamRole string
|
||||
|
|
@ -80,6 +82,16 @@ func (e EmploymentType) IsValid() bool {
|
|||
}
|
||||
}
|
||||
|
||||
// TeamRefreshToken is a persisted refresh token for team member sessions (separate from users.refresh_tokens).
|
||||
type TeamRefreshToken struct {
|
||||
ID int64
|
||||
TeamMemberID int64
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
Revoked bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type TeamMember struct {
|
||||
ID int64
|
||||
FirstName string
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package ports
|
|||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"Yimaru-Backend/internal/domain"
|
||||
)
|
||||
|
|
@ -30,4 +31,9 @@ type TeamStore interface {
|
|||
GetTeamMembersByRole(ctx context.Context, role string) ([]domain.TeamMember, error)
|
||||
CountTeamMembersByStatus(ctx context.Context) (domain.TeamMemberStats, error)
|
||||
UpdateTeamMemberEmailVerified(ctx context.Context, memberID int64, verified bool) error
|
||||
|
||||
RevokeAllActiveTeamRefreshTokensForMember(ctx context.Context, memberID int64) error
|
||||
CreateTeamRefreshToken(ctx context.Context, memberID int64, token string, expiresAt, createdAt time.Time) error
|
||||
GetTeamRefreshTokenByToken(ctx context.Context, token string) (domain.TeamRefreshToken, error)
|
||||
RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error
|
||||
}
|
||||
|
|
|
|||
|
|
@ -455,3 +455,42 @@ func mapGetTeamMembersByRoleRow(row dbgen.GetTeamMembersByRoleRow) domain.TeamMe
|
|||
func parseDate(dateStr string) (time.Time, error) {
|
||||
return time.Parse("2006-01-02", dateStr)
|
||||
}
|
||||
|
||||
func (s *Store) RevokeAllActiveTeamRefreshTokensForMember(ctx context.Context, memberID int64) error {
|
||||
return s.queries.RevokeAllActiveTeamRefreshTokensForMember(ctx, memberID)
|
||||
}
|
||||
|
||||
func (s *Store) CreateTeamRefreshToken(ctx context.Context, memberID int64, token string, expiresAt, createdAt time.Time) error {
|
||||
return s.queries.CreateTeamRefreshToken(ctx, dbgen.CreateTeamRefreshTokenParams{
|
||||
TeamMemberID: memberID,
|
||||
Token: token,
|
||||
ExpiresAt: pgtype.Timestamptz{Time: expiresAt, Valid: true},
|
||||
Revoked: false,
|
||||
CreatedAt: pgtype.Timestamptz{Time: createdAt, Valid: true},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) GetTeamRefreshTokenByToken(ctx context.Context, token string) (domain.TeamRefreshToken, error) {
|
||||
row, err := s.queries.GetTeamRefreshTokenByToken(ctx, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return domain.TeamRefreshToken{}, domain.ErrTeamRefreshTokenNotFound
|
||||
}
|
||||
return domain.TeamRefreshToken{}, err
|
||||
}
|
||||
if !row.ExpiresAt.Valid || !row.CreatedAt.Valid {
|
||||
return domain.TeamRefreshToken{}, domain.ErrTeamRefreshTokenNotFound
|
||||
}
|
||||
return domain.TeamRefreshToken{
|
||||
ID: row.ID,
|
||||
TeamMemberID: row.TeamMemberID,
|
||||
Token: row.Token,
|
||||
ExpiresAt: row.ExpiresAt.Time,
|
||||
Revoked: row.Revoked,
|
||||
CreatedAt: row.CreatedAt.Time,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) RevokeTeamRefreshTokenByToken(ctx context.Context, token string) error {
|
||||
return s.queries.RevokeTeamRefreshTokenByToken(ctx, token)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,10 +6,15 @@ import (
|
|||
|
||||
type Service struct {
|
||||
teamStore ports.TeamStore
|
||||
refreshExpirySec int
|
||||
}
|
||||
|
||||
func NewService(teamStore ports.TeamStore) *Service {
|
||||
func NewService(teamStore ports.TeamStore, refreshExpirySeconds int) *Service {
|
||||
if refreshExpirySeconds <= 0 {
|
||||
refreshExpirySeconds = 7 * 24 * 3600
|
||||
}
|
||||
return &Service{
|
||||
teamStore: teamStore,
|
||||
refreshExpirySec: refreshExpirySeconds,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
69
internal/services/team/tokens.go
Normal file
69
internal/services/team/tokens.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package team
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base32"
|
||||
"time"
|
||||
|
||||
"Yimaru-Backend/internal/domain"
|
||||
)
|
||||
|
||||
func generateOpaqueRefreshToken() (string, error) {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// IssueRefreshTokenOnLogin revokes prior active team refresh tokens for the member and stores a new one.
|
||||
func (s *Service) IssueRefreshTokenOnLogin(ctx context.Context, memberID int64) (string, error) {
|
||||
if err := s.teamStore.RevokeAllActiveTeamRefreshTokensForMember(ctx, memberID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
tok, err := generateOpaqueRefreshToken()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
now := time.Now()
|
||||
exp := now.Add(time.Duration(s.refreshExpirySec) * time.Second)
|
||||
if err := s.teamStore.CreateTeamRefreshToken(ctx, memberID, tok, exp, now); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return tok, nil
|
||||
}
|
||||
|
||||
// RefreshSession validates a team refresh token, rotates it, and returns the member plus the new refresh token plaintext.
|
||||
func (s *Service) RefreshSession(ctx context.Context, refreshTokenPlain string) (domain.TeamMember, string, error) {
|
||||
rt, err := s.teamStore.GetTeamRefreshTokenByToken(ctx, refreshTokenPlain)
|
||||
if err != nil {
|
||||
return domain.TeamMember{}, "", err
|
||||
}
|
||||
if rt.Revoked {
|
||||
return domain.TeamMember{}, "", domain.ErrTeamRefreshTokenNotFound
|
||||
}
|
||||
if time.Now().After(rt.ExpiresAt) {
|
||||
return domain.TeamMember{}, "", domain.ErrTeamRefreshTokenExpired
|
||||
}
|
||||
if err := s.teamStore.RevokeTeamRefreshTokenByToken(ctx, refreshTokenPlain); err != nil {
|
||||
return domain.TeamMember{}, "", err
|
||||
}
|
||||
newTok, err := generateOpaqueRefreshToken()
|
||||
if err != nil {
|
||||
return domain.TeamMember{}, "", err
|
||||
}
|
||||
now := time.Now()
|
||||
exp := now.Add(time.Duration(s.refreshExpirySec) * time.Second)
|
||||
if err := s.teamStore.CreateTeamRefreshToken(ctx, rt.TeamMemberID, newTok, exp, now); err != nil {
|
||||
return domain.TeamMember{}, "", err
|
||||
}
|
||||
member, err := s.teamStore.GetTeamMemberByID(ctx, rt.TeamMemberID)
|
||||
if err != nil {
|
||||
return domain.TeamMember{}, "", err
|
||||
}
|
||||
if member.Status != domain.TeamMemberStatusActive {
|
||||
return domain.TeamMember{}, "", domain.ErrInvalidTeamMemberStatus
|
||||
}
|
||||
return member, newTok, nil
|
||||
}
|
||||
|
|
@ -17,11 +17,15 @@ import (
|
|||
// teamMemberLoginRes represents the response body for team member login
|
||||
type teamMemberLoginRes struct {
|
||||
AccessToken string `json:"access_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`
|
||||
RefreshToken string `json:"refresh_token" example:""`
|
||||
RefreshToken string `json:"refresh_token" example:"<opaque-refresh-token>"`
|
||||
MemberID int64 `json:"member_id" example:"1"`
|
||||
TeamRole string `json:"team_role" example:"admin"`
|
||||
}
|
||||
|
||||
type teamMemberRefreshReq struct {
|
||||
RefreshToken string `json:"refresh_token" validate:"required"`
|
||||
}
|
||||
|
||||
// changePasswordReq represents the request body for changing password
|
||||
type changePasswordReq struct {
|
||||
CurrentPassword string `json:"current_password" validate:"required" example:"oldpassword123"`
|
||||
|
|
@ -139,6 +143,20 @@ func (h *Handler) TeamMemberLogin(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
refreshTokenStr, err := h.teamSvc.IssueRefreshTokenOnLogin(c.Context(), loginRes.ID)
|
||||
if err != nil {
|
||||
h.mongoLoggerSvc.Error("Failed to issue team refresh token",
|
||||
zap.Int("status_code", fiber.StatusInternalServerError),
|
||||
zap.Int64("member_id", loginRes.ID),
|
||||
zap.Error(err),
|
||||
zap.Time("timestamp", time.Now()),
|
||||
)
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Failed to login",
|
||||
Error: "Failed to issue refresh token",
|
||||
})
|
||||
}
|
||||
|
||||
h.mongoLoggerSvc.Info("Team member login successful",
|
||||
zap.Int("status_code", fiber.StatusOK),
|
||||
zap.Int64("member_id", loginRes.ID),
|
||||
|
|
@ -150,7 +168,7 @@ func (h *Handler) TeamMemberLogin(c *fiber.Ctx) error {
|
|||
Message: "Login successful",
|
||||
Data: teamMemberLoginRes{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: "",
|
||||
RefreshToken: refreshTokenStr,
|
||||
MemberID: loginRes.ID,
|
||||
TeamRole: string(loginRes.TeamRole),
|
||||
},
|
||||
|
|
@ -159,6 +177,81 @@ func (h *Handler) TeamMemberLogin(c *fiber.Ctx) error {
|
|||
})
|
||||
}
|
||||
|
||||
// TeamMemberRefresh godoc
|
||||
// @Summary Refresh team member tokens
|
||||
// @Description Exchanges a valid team refresh token for a new access JWT and a rotated refresh token (use only tokens from POST /team/login or this endpoint).
|
||||
// @Tags team
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body teamMemberRefreshReq true "Current refresh token"
|
||||
// @Success 200 {object} domain.Response{data=teamMemberLoginRes}
|
||||
// @Failure 400 {object} domain.ErrorResponse
|
||||
// @Failure 401 {object} domain.ErrorResponse
|
||||
// @Failure 500 {object} domain.ErrorResponse
|
||||
// @Router /api/v1/team/refresh [post]
|
||||
func (h *Handler) TeamMemberRefresh(c *fiber.Ctx) error {
|
||||
var req teamMemberRefreshReq
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Invalid request body",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
if valErrs, ok := h.validator.Validate(c, req); !ok {
|
||||
var errMsg string
|
||||
for field, msg := range valErrs {
|
||||
errMsg += fmt.Sprintf("%s: %s; ", field, msg)
|
||||
}
|
||||
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
|
||||
Message: "Validation failed",
|
||||
Error: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
member, newRefresh, err := h.teamSvc.RefreshSession(c.Context(), req.RefreshToken)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrTeamRefreshTokenNotFound), errors.Is(err, domain.ErrTeamRefreshTokenExpired):
|
||||
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
|
||||
Message: "Refresh failed",
|
||||
Error: "Invalid or expired refresh token",
|
||||
})
|
||||
case errors.Is(err, domain.ErrInvalidTeamMemberStatus):
|
||||
return c.Status(fiber.StatusForbidden).JSON(domain.ErrorResponse{
|
||||
Message: "Refresh failed",
|
||||
Error: "Account is not active",
|
||||
})
|
||||
default:
|
||||
h.mongoLoggerSvc.Error("Team refresh failed", zap.Error(err), zap.Time("timestamp", time.Now()))
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Refresh failed",
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
role := mapTeamRoleToRole(member.TeamRole)
|
||||
accessToken, err := jwtutil.CreateJwt(member.ID, role, h.jwtConfig.JwtAccessKey, h.jwtConfig.JwtAccessExpiry)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
|
||||
Message: "Refresh failed",
|
||||
Error: "Failed to generate access token",
|
||||
})
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusOK).JSON(domain.Response{
|
||||
Message: "Token refreshed successfully",
|
||||
Data: teamMemberLoginRes{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: newRefresh,
|
||||
MemberID: member.ID,
|
||||
TeamRole: string(member.TeamRole),
|
||||
},
|
||||
Success: true,
|
||||
StatusCode: fiber.StatusOK,
|
||||
})
|
||||
}
|
||||
|
||||
// CreateTeamMember godoc
|
||||
// @Summary Create a new team member
|
||||
// @Description Create a new internal team member (admin only)
|
||||
|
|
|
|||
|
|
@ -330,6 +330,7 @@ func (a *App) initAppRoutes() {
|
|||
// Team Management
|
||||
teamGroup := groupV1.Group("/team")
|
||||
teamGroup.Post("/login", h.TeamMemberLogin)
|
||||
teamGroup.Post("/refresh", h.TeamMemberRefresh)
|
||||
teamGroup.Get("/me", a.authMiddleware, a.RequirePermission("team.profile.get_mine"), h.GetMyTeamProfile)
|
||||
teamGroup.Get("/stats", a.authMiddleware, a.RequirePermission("team.stats"), h.GetTeamMemberStats)
|
||||
teamGroup.Get("/members", a.authMiddleware, a.RequirePermission("team.members.list"), h.GetAllTeamMembers)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user