feat(server/auth): switch user identity to phone with email optional
Phone becomes the required, unique login identifier; email and name turn optional. Login now accepts either via a single identifier field, refresh re-checks tenant membership, and the dev seed provides phone numbers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -37,8 +38,9 @@ func NewAuthService(
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Identifier string `json:"identifier"`
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginResponse struct {
|
||||
@@ -50,8 +52,9 @@ type LoginResponse struct {
|
||||
|
||||
type UserInfo struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
Email *string `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
Name *string `json:"name"`
|
||||
AvatarURL *string `json:"avatar_url"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
PrimaryWorkspaceID int64 `json:"primary_workspace_id"`
|
||||
@@ -86,11 +89,23 @@ type KolProfileBrief struct {
|
||||
}
|
||||
|
||||
func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginResponse, error) {
|
||||
user, err := s.repo.GetUserByEmail(ctx, req.Email)
|
||||
identifier := strings.TrimSpace(req.Identifier)
|
||||
if identifier == "" {
|
||||
identifier = strings.TrimSpace(req.Email)
|
||||
}
|
||||
if identifier == "" {
|
||||
return nil, response.ErrBadRequest(40001, "invalid_params", "login identifier is required")
|
||||
}
|
||||
|
||||
user, err := s.repo.GetUserByLoginIdentifier(ctx, identifier)
|
||||
if err != nil {
|
||||
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
|
||||
}
|
||||
|
||||
if user.Status != "active" {
|
||||
return nil, response.ErrForbidden(40311, "user_disabled", "user account is disabled")
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
return nil, response.ErrUnauthorized(40110, "invalid_credentials", "email or password is incorrect")
|
||||
}
|
||||
@@ -126,6 +141,7 @@ func (s *AuthService) Login(ctx context.Context, req LoginRequest) (*LoginRespon
|
||||
User: UserInfo{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
Name: user.Name,
|
||||
AvatarURL: user.AvatarURL,
|
||||
TenantID: membership.TenantID,
|
||||
@@ -154,7 +170,12 @@ func (s *AuthService) Refresh(ctx context.Context, req RefreshRequest) (*Refresh
|
||||
return nil, response.ErrUnauthorized(40120, "invalid_refresh_token", "refresh token is invalid or expired")
|
||||
}
|
||||
|
||||
pair, err := s.jwt.Issue(claims.UserID, claims.TenantID, claims.PrimaryWorkspaceID, claims.Role)
|
||||
membership, err := s.repo.GetTenantMembershipByTenantAndUser(ctx, claims.TenantID, claims.UserID)
|
||||
if err != nil {
|
||||
return nil, response.ErrUnauthorized(40123, "membership_revoked", "tenant membership is no longer active")
|
||||
}
|
||||
|
||||
pair, err := s.jwt.Issue(membership.UserID, membership.TenantID, membership.PrimaryWorkspaceID, membership.TenantRole)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("issue token: %w", err)
|
||||
}
|
||||
@@ -202,6 +223,7 @@ func (s *AuthService) Me(ctx context.Context, actor auth.Actor) (*UserInfo, erro
|
||||
return &UserInfo{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Phone: user.Phone,
|
||||
Name: user.Name,
|
||||
AvatarURL: user.AvatarURL,
|
||||
TenantID: actor.TenantID,
|
||||
|
||||
@@ -4,10 +4,10 @@ import "time"
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
Email string
|
||||
Phone *string
|
||||
Email *string
|
||||
Phone string
|
||||
PasswordHash string
|
||||
Name string
|
||||
Name *string
|
||||
AvatarURL *string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
|
||||
@@ -4,15 +4,17 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/tenant/repository/generated"
|
||||
)
|
||||
|
||||
type UserRecord struct {
|
||||
ID int64
|
||||
Email string
|
||||
Phone *string
|
||||
Email *string
|
||||
Phone string
|
||||
PasswordHash string
|
||||
Name string
|
||||
Name *string
|
||||
AvatarURL *string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
@@ -30,6 +32,7 @@ type MembershipRecord struct {
|
||||
|
||||
type AuthRepository interface {
|
||||
GetUserByEmail(ctx context.Context, email string) (*UserRecord, error)
|
||||
GetUserByLoginIdentifier(ctx context.Context, identifier string) (*UserRecord, error)
|
||||
GetUserByID(ctx context.Context, id int64) (*UserRecord, error)
|
||||
GetTenantMembership(ctx context.Context, userID int64) (*MembershipRecord, error)
|
||||
GetTenantMembershipByTenantAndUser(ctx context.Context, tenantID, userID int64) (*MembershipRecord, error)
|
||||
@@ -44,17 +47,36 @@ func NewAuthRepository(db generated.DBTX) AuthRepository {
|
||||
}
|
||||
|
||||
func (r *authRepository) GetUserByEmail(ctx context.Context, email string) (*UserRecord, error) {
|
||||
row, err := r.q.GetUserByEmail(ctx, email)
|
||||
row, err := r.q.GetUserByEmail(ctx, pgtype.Text{String: email, Valid: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UserRecord{
|
||||
ID: row.ID,
|
||||
Email: row.Email,
|
||||
Phone: nullableText(row.Phone),
|
||||
Email: nullableText(row.Email),
|
||||
Phone: row.Phone,
|
||||
PasswordHash: row.PasswordHash,
|
||||
Name: row.Name,
|
||||
Name: nullableText(row.Name),
|
||||
AvatarURL: nullableText(row.AvatarUrl),
|
||||
Status: row.Status,
|
||||
CreatedAt: timeFromTimestamp(row.CreatedAt),
|
||||
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *authRepository) GetUserByLoginIdentifier(ctx context.Context, identifier string) (*UserRecord, error) {
|
||||
row, err := r.q.GetUserByLoginIdentifier(ctx, pgtype.Text{String: identifier, Valid: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &UserRecord{
|
||||
ID: row.ID,
|
||||
Email: nullableText(row.Email),
|
||||
Phone: row.Phone,
|
||||
PasswordHash: row.PasswordHash,
|
||||
Name: nullableText(row.Name),
|
||||
AvatarURL: nullableText(row.AvatarUrl),
|
||||
Status: row.Status,
|
||||
CreatedAt: timeFromTimestamp(row.CreatedAt),
|
||||
@@ -70,10 +92,10 @@ func (r *authRepository) GetUserByID(ctx context.Context, id int64) (*UserRecord
|
||||
|
||||
return &UserRecord{
|
||||
ID: row.ID,
|
||||
Email: row.Email,
|
||||
Phone: nullableText(row.Phone),
|
||||
Email: nullableText(row.Email),
|
||||
Phone: row.Phone,
|
||||
PasswordHash: row.PasswordHash,
|
||||
Name: row.Name,
|
||||
Name: nullableText(row.Name),
|
||||
AvatarURL: nullableText(row.AvatarUrl),
|
||||
Status: row.Status,
|
||||
CreatedAt: timeFromTimestamp(row.CreatedAt),
|
||||
|
||||
@@ -101,17 +101,17 @@ WHERE email = $1 AND deleted_at IS NULL
|
||||
|
||||
type GetUserByEmailRow struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Phone pgtype.Text `json:"phone"`
|
||||
Email pgtype.Text `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Name string `json:"name"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) {
|
||||
func (q *Queries) GetUserByEmail(ctx context.Context, email pgtype.Text) (GetUserByEmailRow, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByEmail, email)
|
||||
var i GetUserByEmailRow
|
||||
err := row.Scan(
|
||||
@@ -136,10 +136,10 @@ WHERE id = $1 AND deleted_at IS NULL
|
||||
|
||||
type GetUserByIDRow struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Phone pgtype.Text `json:"phone"`
|
||||
Email pgtype.Text `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Name string `json:"name"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
@@ -162,3 +162,39 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, er
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByLoginIdentifier = `-- name: GetUserByLoginIdentifier :one
|
||||
SELECT id, email, phone, password_hash, name, avatar_url, status, created_at, updated_at
|
||||
FROM users
|
||||
WHERE (email = $1 OR phone = $1)
|
||||
AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
type GetUserByLoginIdentifierRow struct {
|
||||
ID int64 `json:"id"`
|
||||
Email pgtype.Text `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserByLoginIdentifier(ctx context.Context, loginIdentifier pgtype.Text) (GetUserByLoginIdentifierRow, error) {
|
||||
row := q.db.QueryRow(ctx, getUserByLoginIdentifier, loginIdentifier)
|
||||
var i GetUserByLoginIdentifierRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Phone,
|
||||
&i.PasswordHash,
|
||||
&i.Name,
|
||||
&i.AvatarUrl,
|
||||
&i.Status,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -21,6 +21,27 @@ type AiPlatform struct {
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type AiPointUsageLog struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
OperatorID pgtype.Int8 `json:"operator_id"`
|
||||
QuotaReservationID pgtype.Int8 `json:"quota_reservation_id"`
|
||||
UsageType string `json:"usage_type"`
|
||||
ResourceType pgtype.Text `json:"resource_type"`
|
||||
ResourceID pgtype.Int8 `json:"resource_id"`
|
||||
ResourceUid pgtype.Text `json:"resource_uid"`
|
||||
RequestChars int32 `json:"request_chars"`
|
||||
BaseChars int32 `json:"base_chars"`
|
||||
Points int32 `json:"points"`
|
||||
Status string `json:"status"`
|
||||
Model pgtype.Text `json:"model"`
|
||||
MetadataJson []byte `json:"metadata_json"`
|
||||
ErrorMessage pgtype.Text `json:"error_message"`
|
||||
CompletedAt pgtype.Timestamptz `json:"completed_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Article struct {
|
||||
ID int64 `json:"id"`
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
@@ -149,6 +170,15 @@ type DesktopClient struct {
|
||||
RevokedAt pgtype.Timestamptz `json:"revoked_at"`
|
||||
}
|
||||
|
||||
type DesktopClientPrimaryLease struct {
|
||||
TenantID int64 `json:"tenant_id"`
|
||||
WorkspaceID int64 `json:"workspace_id"`
|
||||
PrimaryClientID pgtype.UUID `json:"primary_client_id"`
|
||||
LeaseExpiresAt pgtype.Timestamptz `json:"lease_expires_at"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
|
||||
}
|
||||
|
||||
type DesktopPublishJob struct {
|
||||
ID int64 `json:"id"`
|
||||
DesktopID pgtype.UUID `json:"desktop_id"`
|
||||
@@ -708,10 +738,10 @@ type TenantQuotaLedger struct {
|
||||
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Phone pgtype.Text `json:"phone"`
|
||||
Email pgtype.Text `json:"email"`
|
||||
Phone string `json:"phone"`
|
||||
PasswordHash string `json:"password_hash"`
|
||||
Name string `json:"name"`
|
||||
Name pgtype.Text `json:"name"`
|
||||
AvatarUrl pgtype.Text `json:"avatar_url"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt pgtype.Timestamptz `json:"created_at"`
|
||||
|
||||
@@ -6,6 +6,8 @@ package generated
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
type Querier interface {
|
||||
@@ -90,8 +92,9 @@ type Querier interface {
|
||||
GetTemplateByID(ctx context.Context, arg GetTemplateByIDParams) (GetTemplateByIDRow, error)
|
||||
GetTenantMembership(ctx context.Context, userID int64) (GetTenantMembershipRow, error)
|
||||
GetTenantMembershipByTenantAndUser(ctx context.Context, arg GetTenantMembershipByTenantAndUserParams) (GetTenantMembershipByTenantAndUserRow, error)
|
||||
GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error)
|
||||
GetUserByEmail(ctx context.Context, email pgtype.Text) (GetUserByEmailRow, error)
|
||||
GetUserByID(ctx context.Context, id int64) (GetUserByIDRow, error)
|
||||
GetUserByLoginIdentifier(ctx context.Context, loginIdentifier pgtype.Text) (GetUserByLoginIdentifierRow, error)
|
||||
HeartbeatDesktopClient(ctx context.Context, arg HeartbeatDesktopClientParams) (DesktopClient, error)
|
||||
InsertImageAsset(ctx context.Context, arg InsertImageAssetParams) (int64, error)
|
||||
InsertQuotaLedger(ctx context.Context, arg InsertQuotaLedgerParams) (int64, error)
|
||||
|
||||
@@ -3,6 +3,12 @@ SELECT id, email, phone, password_hash, name, avatar_url, status, created_at, up
|
||||
FROM users
|
||||
WHERE email = sqlc.arg(email) AND deleted_at IS NULL;
|
||||
|
||||
-- name: GetUserByLoginIdentifier :one
|
||||
SELECT id, email, phone, password_hash, name, avatar_url, status, created_at, updated_at
|
||||
FROM users
|
||||
WHERE (email = sqlc.arg(login_identifier) OR phone = sqlc.arg(login_identifier))
|
||||
AND deleted_at IS NULL;
|
||||
|
||||
-- name: GetUserByID :one
|
||||
SELECT id, email, phone, password_hash, name, avatar_url, status, created_at, updated_at
|
||||
FROM users
|
||||
|
||||
Reference in New Issue
Block a user