refresh token fix
This commit is contained in:
parent
886b62ed68
commit
ce1b827768
|
|
@ -414,7 +414,7 @@ func main() {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Team management service
|
// Team management service
|
||||||
teamSvc := team.NewService(repository.NewTeamStore(store))
|
teamSvc := team.NewService(repository.NewTeamStore(store), cfg.RefreshExpiry)
|
||||||
|
|
||||||
// santimpayClient := santimpay.NewSantimPayClient(cfg)
|
// 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": {
|
"/api/v1/team/stats": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
|
|
@ -11609,7 +11673,7 @@ const docTemplate = `{
|
||||||
},
|
},
|
||||||
"refresh_token": {
|
"refresh_token": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": ""
|
"example": "\u003copaque-refresh-token\u003e"
|
||||||
},
|
},
|
||||||
"team_role": {
|
"team_role": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -11617,6 +11681,17 @@ const docTemplate = `{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"handlers.teamMemberRefreshReq": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"refresh_token"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"refresh_token": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"handlers.updateAdminReq": {
|
"handlers.updateAdminReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"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": {
|
"/api/v1/team/stats": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
|
|
@ -11601,7 +11665,7 @@
|
||||||
},
|
},
|
||||||
"refresh_token": {
|
"refresh_token": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": ""
|
"example": "\u003copaque-refresh-token\u003e"
|
||||||
},
|
},
|
||||||
"team_role": {
|
"team_role": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -11609,6 +11673,17 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"handlers.teamMemberRefreshReq": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"refresh_token"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"refresh_token": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"handlers.updateAdminReq": {
|
"handlers.updateAdminReq": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
|
||||||
|
|
@ -1448,12 +1448,19 @@ definitions:
|
||||||
example: 1
|
example: 1
|
||||||
type: integer
|
type: integer
|
||||||
refresh_token:
|
refresh_token:
|
||||||
example: ""
|
example: <opaque-refresh-token>
|
||||||
type: string
|
type: string
|
||||||
team_role:
|
team_role:
|
||||||
example: admin
|
example: admin
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
handlers.teamMemberRefreshReq:
|
||||||
|
properties:
|
||||||
|
refresh_token:
|
||||||
|
type: string
|
||||||
|
required:
|
||||||
|
- refresh_token
|
||||||
|
type: object
|
||||||
handlers.updateAdminReq:
|
handlers.updateAdminReq:
|
||||||
properties:
|
properties:
|
||||||
first_name:
|
first_name:
|
||||||
|
|
@ -7117,6 +7124,46 @@ paths:
|
||||||
summary: Update team member status
|
summary: Update team member status
|
||||||
tags:
|
tags:
|
||||||
- team
|
- 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:
|
/api/v1/team/stats:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
|
|
|
||||||
|
|
@ -477,6 +477,15 @@ type TeamMember struct {
|
||||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
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 {
|
type User struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
FirstName pgtype.Text `json:"first_name"`
|
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")
|
ErrInvalidTeamMemberStatus = errors.New("invalid team member status")
|
||||||
ErrInvalidEmploymentType = errors.New("invalid employment type")
|
ErrInvalidEmploymentType = errors.New("invalid employment type")
|
||||||
ErrTeamMemberEmailNotVerified = errors.New("team member email not verified")
|
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
|
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 {
|
type TeamMember struct {
|
||||||
ID int64
|
ID int64
|
||||||
FirstName string
|
FirstName string
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package ports
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"Yimaru-Backend/internal/domain"
|
"Yimaru-Backend/internal/domain"
|
||||||
)
|
)
|
||||||
|
|
@ -30,4 +31,9 @@ type TeamStore interface {
|
||||||
GetTeamMembersByRole(ctx context.Context, role string) ([]domain.TeamMember, error)
|
GetTeamMembersByRole(ctx context.Context, role string) ([]domain.TeamMember, error)
|
||||||
CountTeamMembersByStatus(ctx context.Context) (domain.TeamMemberStats, error)
|
CountTeamMembersByStatus(ctx context.Context) (domain.TeamMemberStats, error)
|
||||||
UpdateTeamMemberEmailVerified(ctx context.Context, memberID int64, verified bool) 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) {
|
func parseDate(dateStr string) (time.Time, error) {
|
||||||
return time.Parse("2006-01-02", dateStr)
|
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 {
|
type Service struct {
|
||||||
teamStore ports.TeamStore
|
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{
|
return &Service{
|
||||||
teamStore: teamStore,
|
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
|
// teamMemberLoginRes represents the response body for team member login
|
||||||
type teamMemberLoginRes struct {
|
type teamMemberLoginRes struct {
|
||||||
AccessToken string `json:"access_token" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."`
|
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"`
|
MemberID int64 `json:"member_id" example:"1"`
|
||||||
TeamRole string `json:"team_role" example:"admin"`
|
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
|
// changePasswordReq represents the request body for changing password
|
||||||
type changePasswordReq struct {
|
type changePasswordReq struct {
|
||||||
CurrentPassword string `json:"current_password" validate:"required" example:"oldpassword123"`
|
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",
|
h.mongoLoggerSvc.Info("Team member login successful",
|
||||||
zap.Int("status_code", fiber.StatusOK),
|
zap.Int("status_code", fiber.StatusOK),
|
||||||
zap.Int64("member_id", loginRes.ID),
|
zap.Int64("member_id", loginRes.ID),
|
||||||
|
|
@ -150,7 +168,7 @@ func (h *Handler) TeamMemberLogin(c *fiber.Ctx) error {
|
||||||
Message: "Login successful",
|
Message: "Login successful",
|
||||||
Data: teamMemberLoginRes{
|
Data: teamMemberLoginRes{
|
||||||
AccessToken: accessToken,
|
AccessToken: accessToken,
|
||||||
RefreshToken: "",
|
RefreshToken: refreshTokenStr,
|
||||||
MemberID: loginRes.ID,
|
MemberID: loginRes.ID,
|
||||||
TeamRole: string(loginRes.TeamRole),
|
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
|
// CreateTeamMember godoc
|
||||||
// @Summary Create a new team member
|
// @Summary Create a new team member
|
||||||
// @Description Create a new internal team member (admin only)
|
// @Description Create a new internal team member (admin only)
|
||||||
|
|
|
||||||
|
|
@ -330,6 +330,7 @@ func (a *App) initAppRoutes() {
|
||||||
// Team Management
|
// Team Management
|
||||||
teamGroup := groupV1.Group("/team")
|
teamGroup := groupV1.Group("/team")
|
||||||
teamGroup.Post("/login", h.TeamMemberLogin)
|
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("/me", a.authMiddleware, a.RequirePermission("team.profile.get_mine"), h.GetMyTeamProfile)
|
||||||
teamGroup.Get("/stats", a.authMiddleware, a.RequirePermission("team.stats"), h.GetTeamMemberStats)
|
teamGroup.Get("/stats", a.authMiddleware, a.RequirePermission("team.stats"), h.GetTeamMemberStats)
|
||||||
teamGroup.Get("/members", a.authMiddleware, a.RequirePermission("team.members.list"), h.GetAllTeamMembers)
|
teamGroup.Get("/members", a.authMiddleware, a.RequirePermission("team.members.list"), h.GetAllTeamMembers)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user