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
+6 -6
View File
@@ -326,15 +326,15 @@ func ensureNamedTenant(ctx context.Context, tx pgx.Tx, name string) (int64, erro
}
func ensureUser(ctx context.Context, tx pgx.Tx, passwordHash string) (int64, error) {
return ensureNamedUser(ctx, tx, "admin@geo.local", "Admin", passwordHash)
return ensureNamedUser(ctx, tx, "admin@geo.local", "13800138000", "Admin", passwordHash)
}
func ensureNamedUser(ctx context.Context, tx pgx.Tx, email, name, passwordHash string) (int64, error) {
func ensureNamedUser(ctx context.Context, tx pgx.Tx, email, phone, name, passwordHash string) (int64, error) {
var userID int64
err := tx.QueryRow(ctx, `
WITH ensured AS (
INSERT INTO users (email, password_hash, name, status)
VALUES ($1, $2, $3, 'active')
INSERT INTO users (email, phone, password_hash, name, status)
VALUES ($1, $2, $3, $4, 'active')
ON CONFLICT DO NOTHING
RETURNING id
)
@@ -342,7 +342,7 @@ func ensureNamedUser(ctx context.Context, tx pgx.Tx, email, name, passwordHash s
UNION ALL
SELECT id FROM users WHERE email = $1
LIMIT 1
`, email, passwordHash, name).Scan(&userID)
`, email, phone, passwordHash, name).Scan(&userID)
return userID, wrapSeedErr("insert user", err)
}
@@ -412,7 +412,7 @@ func ensureSecondaryTenantUser(
return 0, 0, err
}
userID, err := ensureNamedUser(ctx, tx, "test@geo.local", "test", string(hash))
userID, err := ensureNamedUser(ctx, tx, "test@geo.local", "13800138001", "test", string(hash))
if err != nil {
return 0, 0, err
}
+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
@@ -1,13 +1,19 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL,
phone VARCHAR(20),
email VARCHAR(255),
phone VARCHAR(20) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
name VARCHAR(100),
avatar_url TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uk_users_email_active ON users(email) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uk_users_email_active ON users(email) WHERE email IS NOT NULL AND deleted_at IS NULL;
CREATE UNIQUE INDEX uk_users_phone_active ON users(phone) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_email_trgm_active ON users USING GIN (email gin_trgm_ops) WHERE email IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX idx_users_phone_trgm_active ON users USING GIN (phone gin_trgm_ops) WHERE deleted_at IS NULL;
CREATE INDEX idx_users_name_trgm_active ON users USING GIN (name gin_trgm_ops) WHERE name IS NOT NULL AND deleted_at IS NULL;
@@ -0,0 +1,24 @@
DROP INDEX IF EXISTS idx_users_name_trgm_active;
DROP INDEX IF EXISTS idx_users_phone_trgm_active;
DROP INDEX IF EXISTS idx_users_email_trgm_active;
DROP INDEX IF EXISTS idx_users_status_created_at;
DROP INDEX IF EXISTS uk_users_phone_active;
UPDATE users
SET email = 'user-' || id || '@rollback.local'
WHERE email IS NULL;
UPDATE users
SET name = COALESCE(NULLIF(phone, ''), email, 'user-' || id)
WHERE name IS NULL;
ALTER TABLE users
ALTER COLUMN email SET NOT NULL,
ALTER COLUMN phone DROP NOT NULL,
ALTER COLUMN name SET NOT NULL;
DROP INDEX IF EXISTS uk_users_email_active;
CREATE UNIQUE INDEX uk_users_email_active
ON users(email)
WHERE deleted_at IS NULL;
@@ -0,0 +1,36 @@
CREATE EXTENSION IF NOT EXISTS pg_trgm;
UPDATE users
SET phone = '0' || LPAD(id::text, 19, '0')
WHERE phone IS NULL;
ALTER TABLE users
ALTER COLUMN email DROP NOT NULL,
ALTER COLUMN phone SET NOT NULL,
ALTER COLUMN name DROP NOT NULL;
DROP INDEX IF EXISTS uk_users_email_active;
CREATE UNIQUE INDEX uk_users_email_active
ON users(email)
WHERE email IS NOT NULL AND deleted_at IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS uk_users_phone_active
ON users(phone)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_users_status_created_at
ON users(status, created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_users_email_trgm_active
ON users USING GIN (email gin_trgm_ops)
WHERE email IS NOT NULL AND deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_users_phone_trgm_active
ON users USING GIN (phone gin_trgm_ops)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_users_name_trgm_active
ON users USING GIN (name gin_trgm_ops)
WHERE name IS NOT NULL AND deleted_at IS NULL;