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:
2026-04-29 00:01:36 +08:00
parent a6b17bae62
commit ee21f512a9
11 changed files with 225 additions and 40 deletions
+28 -6
View File
@@ -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,
+3 -3
View File
@@ -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
+32 -10
View File
@@ -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