58f11302fe
Track authenticated sessions per device (parsing user-agent for desktop vs mobile via mileusna/useragent), enforce configurable concurrent device limits (Auth.DeviceLimits), and surface device status and "remove other devices" management in the account dialog. Adds device/session context to the auth module stores and exposes limits through the API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
807 lines
26 KiB
Go
807 lines
26 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"net/url"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"img_infinite_canvas/internal/domain/design"
|
|
|
|
"github.com/google/uuid"
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
const (
|
|
deviceOnlineWindow = 5 * time.Minute
|
|
sessionTouchWindow = time.Minute
|
|
)
|
|
|
|
type Config struct {
|
|
Region string
|
|
RegionMode string
|
|
TokenSecret string
|
|
TokenTTL time.Duration
|
|
RefreshTokenTTL time.Duration
|
|
CodeTTL time.Duration
|
|
DeviceLimits DeviceLimits
|
|
DevReturnCode bool
|
|
EmailFromName string
|
|
EmailFromEmail string
|
|
BrandName string
|
|
TurnstileRequired bool
|
|
TurnstileSiteKey string
|
|
GoogleClientID string
|
|
GoogleTokenInfo string
|
|
GoogleTimeout time.Duration
|
|
WechatAppID string
|
|
WechatAppSecret string
|
|
WechatRedirect string
|
|
WechatScope string
|
|
WechatAuthURL string
|
|
WechatTokenURL string
|
|
WechatTimeout time.Duration
|
|
}
|
|
|
|
type Service struct {
|
|
store Store
|
|
codeStore VerificationCodeStore
|
|
emailSender EmailSender
|
|
humanVerifier HumanVerifier
|
|
cfg Config
|
|
now func() time.Time
|
|
wechatExchanger func(context.Context, string) (WechatIdentity, error)
|
|
}
|
|
|
|
func NewService(store Store, cfg Config) *Service {
|
|
return NewServiceWithCodeStore(store, store, cfg)
|
|
}
|
|
|
|
func NewServiceWithCodeStore(store Store, codeStore VerificationCodeStore, cfg Config) *Service {
|
|
return NewServiceWithCodeStoreAndEmail(store, codeStore, nil, cfg)
|
|
}
|
|
|
|
func NewServiceWithCodeStoreAndEmail(store Store, codeStore VerificationCodeStore, emailSender EmailSender, cfg Config) *Service {
|
|
return NewServiceWithCodeStoreEmailAndHumanVerifier(store, codeStore, emailSender, nil, cfg)
|
|
}
|
|
|
|
func NewServiceWithCodeStoreEmailAndHumanVerifier(store Store, codeStore VerificationCodeStore, emailSender EmailSender, humanVerifier HumanVerifier, cfg Config) *Service {
|
|
cfg.Region = normalizeRegion(cfg.Region)
|
|
if cfg.Region == "" {
|
|
cfg.Region = RegionChina
|
|
}
|
|
if cfg.TokenTTL <= 0 {
|
|
cfg.TokenTTL = 7 * 24 * time.Hour
|
|
}
|
|
if cfg.RefreshTokenTTL <= 0 {
|
|
cfg.RefreshTokenTTL = 30 * 24 * time.Hour
|
|
}
|
|
if cfg.CodeTTL <= 0 {
|
|
cfg.CodeTTL = 10 * time.Minute
|
|
}
|
|
if cfg.DeviceLimits.Desktop <= 0 {
|
|
cfg.DeviceLimits.Desktop = 2
|
|
}
|
|
if cfg.DeviceLimits.Mobile <= 0 {
|
|
cfg.DeviceLimits.Mobile = 1
|
|
}
|
|
if strings.TrimSpace(cfg.EmailFromName) == "" {
|
|
cfg.EmailFromName = loginEmailFromName
|
|
}
|
|
if strings.TrimSpace(cfg.EmailFromEmail) == "" {
|
|
cfg.EmailFromEmail = loginEmailFromEmail
|
|
}
|
|
if strings.TrimSpace(cfg.BrandName) == "" {
|
|
cfg.BrandName = loginEmailBrandName
|
|
}
|
|
if strings.TrimSpace(cfg.WechatScope) == "" {
|
|
cfg.WechatScope = "snsapi_login"
|
|
}
|
|
if strings.TrimSpace(cfg.WechatAuthURL) == "" {
|
|
cfg.WechatAuthURL = "https://open.weixin.qq.com/connect/qrconnect"
|
|
}
|
|
if strings.TrimSpace(cfg.WechatTokenURL) == "" {
|
|
cfg.WechatTokenURL = "https://api.weixin.qq.com/sns/oauth2/access_token"
|
|
}
|
|
if strings.TrimSpace(cfg.GoogleTokenInfo) == "" {
|
|
cfg.GoogleTokenInfo = "https://oauth2.googleapis.com/tokeninfo"
|
|
}
|
|
if codeStore == nil {
|
|
codeStore = store
|
|
}
|
|
service := &Service{store: store, codeStore: codeStore, emailSender: emailSender, humanVerifier: humanVerifier, cfg: cfg, now: time.Now}
|
|
service.wechatExchanger = service.exchangeWechatCodeHTTP
|
|
return service
|
|
}
|
|
|
|
func (s *Service) Options() Options {
|
|
turnstile := TurnstileOptions{Enabled: s.cfg.TurnstileRequired, SiteKey: strings.TrimSpace(s.cfg.TurnstileSiteKey)}
|
|
region := s.cfg.Region
|
|
if region == RegionGlobal {
|
|
return Options{Region: region, Methods: []Method{
|
|
{ID: "google", Label: "Google", Provider: "google"},
|
|
{ID: "email_code", Label: "Email code", Provider: "email"},
|
|
}, Turnstile: turnstile}
|
|
}
|
|
return Options{Region: region, Methods: []Method{
|
|
{ID: "wechat_qr", Label: "WeChat QR", Provider: "wechat"},
|
|
{ID: "sms_code", Label: "SMS code", Provider: "sms"},
|
|
{ID: "phone_password", Label: "Phone password", Provider: "password"},
|
|
}, Turnstile: turnstile}
|
|
}
|
|
|
|
func (s *Service) DeviceLimits() DeviceLimits {
|
|
return s.cfg.DeviceLimits
|
|
}
|
|
|
|
func (s *Service) RequestChinaSMSCode(ctx context.Context, phone string, countryCode string, purpose string) (CodeResult, error) {
|
|
if err := s.requireRegion(RegionChina); err != nil {
|
|
return CodeResult{}, err
|
|
}
|
|
return s.createCode(ctx, RegionChina, ChannelSMS, normalizePhone(phone), normalizeCountryCode(countryCode), normalizePurpose(purpose))
|
|
}
|
|
|
|
func (s *Service) LoginChinaSMSCode(ctx context.Context, phone string, countryCode string, code string) (Session, error) {
|
|
if err := s.requireRegion(RegionChina); err != nil {
|
|
return Session{}, err
|
|
}
|
|
phone = normalizePhone(phone)
|
|
countryCode = normalizeCountryCode(countryCode)
|
|
target := phoneTarget(countryCode, phone)
|
|
if err := s.verifyCode(ctx, RegionChina, ChannelSMS, target, PurposeLogin, code); err != nil {
|
|
return Session{}, err
|
|
}
|
|
user, err := s.store.FindUserByPhone(ctx, countryCode, phone)
|
|
if err != nil {
|
|
if !errors.Is(err, design.ErrNotFound) {
|
|
return Session{}, err
|
|
}
|
|
user = User{ID: newID(), Region: RegionChina, Phone: phone, CountryCode: countryCode, Status: "active"}
|
|
}
|
|
user.Region = RegionChina
|
|
user.Phone = phone
|
|
user.CountryCode = countryCode
|
|
user.Status = activeStatus(user.Status)
|
|
return s.upsertAndSign(ctx, user)
|
|
}
|
|
|
|
func (s *Service) LoginChinaPassword(ctx context.Context, phone string, countryCode string, password string) (Session, error) {
|
|
if err := s.requireRegion(RegionChina); err != nil {
|
|
return Session{}, err
|
|
}
|
|
user, err := s.store.FindUserByPhone(ctx, normalizeCountryCode(countryCode), normalizePhone(phone))
|
|
if err != nil {
|
|
return Session{}, fmt.Errorf("%w: phone or password is wrong", ErrInvalidCredentials)
|
|
}
|
|
if strings.TrimSpace(user.PasswordHash) == "" {
|
|
return Session{}, fmt.Errorf("%w: password login is not enabled for this account", ErrInvalidCredentials)
|
|
}
|
|
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
|
return Session{}, fmt.Errorf("%w: phone or password is wrong", ErrInvalidCredentials)
|
|
}
|
|
user.Region = RegionChina
|
|
return s.upsertAndSign(ctx, user)
|
|
}
|
|
|
|
func (s *Service) WechatAuthURL(ctx context.Context) (string, string, int64, error) {
|
|
if err := s.requireRegion(RegionChina); err != nil {
|
|
return "", "", 0, err
|
|
}
|
|
if strings.TrimSpace(s.cfg.WechatAppID) == "" || strings.TrimSpace(s.cfg.WechatRedirect) == "" {
|
|
return "", "", 0, ErrProviderNotReady
|
|
}
|
|
state, expiresIn, err := signState(s.cfg.TokenSecret, s.cfg.CodeTTL, s.now())
|
|
if err != nil {
|
|
return "", "", 0, err
|
|
}
|
|
values := url.Values{}
|
|
values.Set("appid", s.cfg.WechatAppID)
|
|
values.Set("redirect_uri", s.cfg.WechatRedirect)
|
|
values.Set("response_type", "code")
|
|
values.Set("scope", s.cfg.WechatScope)
|
|
values.Set("state", state)
|
|
authURL := strings.TrimRight(s.cfg.WechatAuthURL, "?") + "?" + values.Encode() + "#wechat_redirect"
|
|
if err := ctx.Err(); err != nil {
|
|
return "", "", 0, err
|
|
}
|
|
return authURL, state, expiresIn, nil
|
|
}
|
|
|
|
func (s *Service) LoginChinaWechat(ctx context.Context, code string, state string, bindingToken string, phone string, countryCode string, smsCode string) (Session, error) {
|
|
if err := s.requireRegion(RegionChina); err != nil {
|
|
return Session{}, err
|
|
}
|
|
if strings.TrimSpace(bindingToken) != "" {
|
|
return s.bindWechatPhone(ctx, bindingToken, phone, countryCode, smsCode)
|
|
}
|
|
if err := verifyState(ctx, state, s.cfg.TokenSecret, s.now()); err != nil {
|
|
return Session{}, err
|
|
}
|
|
exchanger := s.wechatExchanger
|
|
if exchanger == nil {
|
|
exchanger = s.exchangeWechatCodeHTTP
|
|
}
|
|
identity, err := exchanger(ctx, code)
|
|
if err != nil {
|
|
return Session{}, normalizeTokenErr(err)
|
|
}
|
|
return s.loginWechatIdentity(ctx, identity)
|
|
}
|
|
|
|
func (s *Service) loginWechatIdentity(ctx context.Context, identity WechatIdentity) (Session, error) {
|
|
user, err := s.store.FindUserByWechat(ctx, identity.OpenID, identity.UnionID)
|
|
if err != nil {
|
|
if !errors.Is(err, design.ErrNotFound) {
|
|
return Session{}, err
|
|
}
|
|
return s.newWechatBindingSession(identity)
|
|
}
|
|
if normalizePhone(user.Phone) == "" {
|
|
return s.newWechatBindingSession(identity)
|
|
}
|
|
if err := ensureWechatIdentityCompatible(user, identity); err != nil {
|
|
return Session{}, err
|
|
}
|
|
user.Region = RegionChina
|
|
user.WechatOpenID = identity.OpenID
|
|
if identity.UnionID != "" {
|
|
user.WechatUnionID = identity.UnionID
|
|
}
|
|
user.Status = activeStatus(user.Status)
|
|
return s.upsertAndSign(ctx, user)
|
|
}
|
|
|
|
func (s *Service) bindWechatPhone(ctx context.Context, bindingToken string, phone string, countryCode string, smsCode string) (Session, error) {
|
|
identity, ok := parseWechatBindingToken(ctx, bindingToken, s.cfg.TokenSecret, s.now())
|
|
if !ok {
|
|
return Session{}, fmt.Errorf("%w: wechat binding token is invalid", ErrInvalidCredentials)
|
|
}
|
|
phone = normalizePhone(phone)
|
|
countryCode = normalizeCountryCode(countryCode)
|
|
if phone == "" {
|
|
return Session{}, fmt.Errorf("%w: phone is required", ErrInvalidCredentials)
|
|
}
|
|
target := phoneTarget(countryCode, phone)
|
|
if err := s.verifyCode(ctx, RegionChina, ChannelSMS, target, PurposeWechatBind, smsCode); err != nil {
|
|
return Session{}, err
|
|
}
|
|
user, err := s.userForWechatBinding(ctx, identity, countryCode, phone)
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
user.Region = RegionChina
|
|
user.Phone = phone
|
|
user.CountryCode = countryCode
|
|
user.WechatOpenID = identity.OpenID
|
|
user.WechatUnionID = identity.UnionID
|
|
user.Status = activeStatus(user.Status)
|
|
return s.upsertAndSign(ctx, user)
|
|
}
|
|
|
|
func (s *Service) userForWechatBinding(ctx context.Context, identity WechatIdentity, countryCode string, phone string) (User, error) {
|
|
wechatUser, wechatErr := s.store.FindUserByWechat(ctx, identity.OpenID, identity.UnionID)
|
|
if wechatErr != nil && !errors.Is(wechatErr, design.ErrNotFound) {
|
|
return User{}, wechatErr
|
|
}
|
|
phoneUser, phoneErr := s.store.FindUserByPhone(ctx, countryCode, phone)
|
|
if phoneErr != nil && !errors.Is(phoneErr, design.ErrNotFound) {
|
|
return User{}, phoneErr
|
|
}
|
|
if phoneErr == nil {
|
|
if wechatErr == nil && wechatUser.ID != phoneUser.ID {
|
|
return User{}, fmt.Errorf("%w: wechat is already bound to another account", ErrInvalidCredentials)
|
|
}
|
|
if err := ensureWechatIdentityCompatible(phoneUser, identity); err != nil {
|
|
return User{}, err
|
|
}
|
|
return phoneUser, nil
|
|
}
|
|
if wechatErr == nil {
|
|
if err := ensureWechatIdentityCompatible(wechatUser, identity); err != nil {
|
|
return User{}, err
|
|
}
|
|
return wechatUser, nil
|
|
}
|
|
return User{ID: newID(), Region: RegionChina, Status: "active"}, nil
|
|
}
|
|
|
|
func (s *Service) newWechatBindingSession(identity WechatIdentity) (Session, error) {
|
|
token, expiresIn, err := signWechatBindingToken(identity, s.cfg.TokenSecret, s.cfg.CodeTTL, s.now())
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
return Session{Status: SessionStatusPhoneBindingRequired, BindingToken: token, BindingExpiresIn: expiresIn}, nil
|
|
}
|
|
|
|
func ensureWechatIdentityCompatible(user User, identity WechatIdentity) error {
|
|
if strings.TrimSpace(user.WechatOpenID) != "" && strings.TrimSpace(user.WechatOpenID) != strings.TrimSpace(identity.OpenID) {
|
|
return fmt.Errorf("%w: account is already bound to another wechat identity", ErrInvalidCredentials)
|
|
}
|
|
if strings.TrimSpace(user.WechatUnionID) != "" && strings.TrimSpace(identity.UnionID) != "" && strings.TrimSpace(user.WechatUnionID) != strings.TrimSpace(identity.UnionID) {
|
|
return fmt.Errorf("%w: account is already bound to another wechat identity", ErrInvalidCredentials)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) RequestGlobalEmailCode(ctx context.Context, email string, purpose string) (CodeResult, error) {
|
|
return s.RequestGlobalEmailCodeWithVerification(ctx, email, purpose, "")
|
|
}
|
|
|
|
func (s *Service) RequestGlobalEmailCodeWithVerification(ctx context.Context, email string, purpose string, humanToken string) (CodeResult, error) {
|
|
if err := s.requireRegion(RegionGlobal); err != nil {
|
|
return CodeResult{}, err
|
|
}
|
|
if err := s.verifyHuman(ctx, humanToken); err != nil {
|
|
return CodeResult{}, err
|
|
}
|
|
return s.createCode(ctx, RegionGlobal, ChannelEmail, normalizeEmail(email), "", normalizePurpose(purpose))
|
|
}
|
|
|
|
func (s *Service) LoginGlobalEmailCode(ctx context.Context, email string, code string) (Session, error) {
|
|
if err := s.requireRegion(RegionGlobal); err != nil {
|
|
return Session{}, err
|
|
}
|
|
email = normalizeEmail(email)
|
|
if err := s.verifyCode(ctx, RegionGlobal, ChannelEmail, email, PurposeLogin, code); err != nil {
|
|
return Session{}, err
|
|
}
|
|
user, err := s.store.FindUserByEmail(ctx, email)
|
|
if err != nil {
|
|
if !errors.Is(err, design.ErrNotFound) {
|
|
return Session{}, err
|
|
}
|
|
user = User{ID: newID(), Region: RegionGlobal, Email: email, Status: "active"}
|
|
}
|
|
user.Region = RegionGlobal
|
|
user.Email = email
|
|
user.Status = activeStatus(user.Status)
|
|
return s.upsertAndSign(ctx, user)
|
|
}
|
|
|
|
func (s *Service) LoginGlobalGoogle(ctx context.Context, idToken string) (Session, error) {
|
|
if err := s.requireRegion(RegionGlobal); err != nil {
|
|
return Session{}, err
|
|
}
|
|
identity, err := s.verifyGoogleIDToken(ctx, idToken)
|
|
if err != nil {
|
|
return Session{}, normalizeTokenErr(err)
|
|
}
|
|
user, err := s.store.FindUserByGoogleSubject(ctx, identity.Subject)
|
|
if err != nil {
|
|
if !errors.Is(err, design.ErrNotFound) {
|
|
return Session{}, err
|
|
}
|
|
user = User{ID: newID(), Region: RegionGlobal, Status: "active"}
|
|
}
|
|
user.Region = RegionGlobal
|
|
user.Email = identity.Email
|
|
user.Name = identity.Name
|
|
user.GoogleSubject = identity.Subject
|
|
user.Status = activeStatus(user.Status)
|
|
return s.upsertAndSign(ctx, user)
|
|
}
|
|
|
|
func (s *Service) UserIDFromBearer(ctx context.Context, authorization string) (string, bool) {
|
|
identity, ok := s.IdentityFromBearer(ctx, authorization)
|
|
return identity.UserID, ok
|
|
}
|
|
|
|
func (s *Service) IdentityFromBearer(ctx context.Context, authorization string) (Identity, bool) {
|
|
token, ok := bearerToken(authorization)
|
|
if !ok {
|
|
return Identity{}, false
|
|
}
|
|
now := s.now().UTC()
|
|
payload, ok := parseAccessTokenPayload(ctx, token, s.cfg.TokenSecret, now)
|
|
if !ok {
|
|
return Identity{}, false
|
|
}
|
|
userID := normalizeUUID(payload.Subject)
|
|
sessionID := strings.TrimSpace(payload.SessionID)
|
|
if sessionID == "" {
|
|
sessionID = legacyDeviceSessionID(token)
|
|
if err := s.ensureLegacyDeviceSession(ctx, sessionID, userID, payload, now); err != nil {
|
|
return Identity{}, false
|
|
}
|
|
}
|
|
|
|
session, err := s.store.FindDeviceSession(ctx, sessionID)
|
|
if err != nil || session.UserID != userID || session.RevokedAt != nil || !session.ExpiresAt.After(now) {
|
|
return Identity{}, false
|
|
}
|
|
if !session.LastSeenAt.After(now.Add(-sessionTouchWindow)) {
|
|
if err := s.touchDeviceSession(ctx, session, now); err != nil {
|
|
return Identity{}, false
|
|
}
|
|
}
|
|
return Identity{UserID: userID, SessionID: sessionID}, true
|
|
}
|
|
|
|
func (s *Service) GetProfile(ctx context.Context, userID string) (User, error) {
|
|
return s.store.FindUserByID(ctx, normalizeUUID(userID))
|
|
}
|
|
|
|
func (s *Service) UpdateProfile(ctx context.Context, userID string, patch ProfilePatch) (User, error) {
|
|
if patch.Name != nil {
|
|
name := strings.TrimSpace(*patch.Name)
|
|
if name == "" || len([]rune(name)) > 40 {
|
|
return User{}, fmt.Errorf("%w: name must be 1-40 characters", ErrInvalidCredentials)
|
|
}
|
|
patch.Name = &name
|
|
}
|
|
if patch.AvatarURL != nil {
|
|
avatarURL := strings.TrimSpace(*patch.AvatarURL)
|
|
if avatarURL != "" && !validAvatarURL(avatarURL) {
|
|
return User{}, fmt.Errorf("%w: avatar url is invalid", ErrInvalidCredentials)
|
|
}
|
|
patch.AvatarURL = &avatarURL
|
|
}
|
|
return s.store.UpdateUserProfile(ctx, normalizeUUID(userID), patch)
|
|
}
|
|
|
|
func (s *Service) ListDevices(ctx context.Context, userID string) ([]Device, error) {
|
|
userID = normalizeUUID(userID)
|
|
if _, err := s.store.FindUserByID(ctx, userID); err != nil {
|
|
return nil, err
|
|
}
|
|
now := s.now().UTC()
|
|
currentSessionID := SessionIDFromContext(ctx)
|
|
for _, policy := range []struct {
|
|
deviceType string
|
|
limit int64
|
|
}{
|
|
{deviceType: "desktop", limit: s.cfg.DeviceLimits.Desktop},
|
|
{deviceType: "mobile", limit: s.cfg.DeviceLimits.Mobile},
|
|
} {
|
|
if _, err := s.store.EnforceDeviceSessionLimit(ctx, userID, policy.deviceType, currentSessionID, policy.limit, now); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
sessions, err := s.store.ListDeviceSessions(ctx, userID, now)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
devices := make([]Device, 0, len(sessions))
|
|
for _, session := range sessions {
|
|
current := session.ID == currentSessionID
|
|
devices = append(devices, Device{
|
|
ID: session.ID,
|
|
Online: current || session.LastSeenAt.After(now.Add(-deviceOnlineWindow)),
|
|
Current: current,
|
|
DeviceType: session.DeviceType,
|
|
System: session.System,
|
|
Browser: session.Browser,
|
|
Client: session.Client,
|
|
LastSeenAt: session.LastSeenAt,
|
|
})
|
|
}
|
|
if currentSessionID == "" && len(devices) == 1 {
|
|
devices[0].Current = true
|
|
devices[0].Online = true
|
|
}
|
|
sort.SliceStable(devices, func(i, j int) bool {
|
|
if devices[i].Current != devices[j].Current {
|
|
return devices[i].Current
|
|
}
|
|
if !devices[i].LastSeenAt.Equal(devices[j].LastSeenAt) {
|
|
return devices[i].LastSeenAt.After(devices[j].LastSeenAt)
|
|
}
|
|
return devices[i].ID < devices[j].ID
|
|
})
|
|
return devices, nil
|
|
}
|
|
|
|
func (s *Service) RemoveOtherDevices(ctx context.Context, userID string) (int64, error) {
|
|
userID = normalizeUUID(userID)
|
|
if _, err := s.store.FindUserByID(ctx, userID); err != nil {
|
|
return 0, err
|
|
}
|
|
currentSessionID := SessionIDFromContext(ctx)
|
|
if err := s.requireActiveDeviceSession(ctx, userID, currentSessionID); err != nil {
|
|
return 0, err
|
|
}
|
|
return s.store.RevokeOtherDeviceSessions(ctx, userID, currentSessionID, s.now().UTC())
|
|
}
|
|
|
|
func (s *Service) Logout(ctx context.Context, userID string) (int64, error) {
|
|
userID = normalizeUUID(userID)
|
|
if _, err := s.store.FindUserByID(ctx, userID); err != nil {
|
|
return 0, err
|
|
}
|
|
currentSessionID := SessionIDFromContext(ctx)
|
|
if err := s.requireActiveDeviceSession(ctx, userID, currentSessionID); err != nil {
|
|
return 0, err
|
|
}
|
|
return s.store.RevokeDeviceSession(ctx, userID, currentSessionID, s.now().UTC())
|
|
}
|
|
|
|
func (s *Service) Close() error {
|
|
if s == nil || s.store == nil {
|
|
return nil
|
|
}
|
|
return s.store.Close()
|
|
}
|
|
|
|
func (s *Service) createCode(ctx context.Context, region string, channel string, target string, countryCode string, purpose string) (CodeResult, error) {
|
|
if target == "" {
|
|
return CodeResult{}, fmt.Errorf("%w: target is required", ErrInvalidCredentials)
|
|
}
|
|
if channel == ChannelSMS {
|
|
target = phoneTarget(countryCode, target)
|
|
}
|
|
code, err := randomDigits(6)
|
|
if err != nil {
|
|
return CodeResult{}, err
|
|
}
|
|
expiresIn := int64(s.cfg.CodeTTL.Seconds())
|
|
expiresAt := s.now().Add(s.cfg.CodeTTL)
|
|
record := VerificationCode{
|
|
ID: newID(),
|
|
Region: region,
|
|
Channel: channel,
|
|
Target: target,
|
|
Purpose: purpose,
|
|
CodeHash: hashVerificationCode(code, target, s.cfg.TokenSecret),
|
|
ExpiresAt: expiresAt,
|
|
}
|
|
if err := s.codeStore.CreateVerificationCode(ctx, record); err != nil {
|
|
return CodeResult{}, err
|
|
}
|
|
result := CodeResult{Target: target, ExpiresIn: expiresIn}
|
|
var email *EmailMessage
|
|
if channel == ChannelEmail {
|
|
email = buildLoginEmail(target, code, EmailTemplateOptions{
|
|
FromName: s.cfg.EmailFromName,
|
|
FromEmail: s.cfg.EmailFromEmail,
|
|
BrandName: s.cfg.BrandName,
|
|
})
|
|
if s.emailSender == nil && !s.cfg.DevReturnCode {
|
|
_ = s.codeStore.MarkVerificationCodeConsumed(ctx, record.ID, s.now())
|
|
return CodeResult{}, ErrProviderNotReady
|
|
}
|
|
if s.emailSender != nil {
|
|
if err := s.emailSender.SendLoginCode(ctx, *email); err != nil {
|
|
_ = s.codeStore.MarkVerificationCodeConsumed(ctx, record.ID, s.now())
|
|
return CodeResult{}, err
|
|
}
|
|
}
|
|
}
|
|
if s.cfg.DevReturnCode {
|
|
result.DevCode = code
|
|
result.Email = email
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *Service) verifyCode(ctx context.Context, region string, channel string, target string, purpose string, code string) error {
|
|
record, err := s.codeStore.FindActiveVerificationCode(ctx, region, channel, target, purpose)
|
|
if err != nil {
|
|
return ErrCodeInvalid
|
|
}
|
|
if record.ExpiresAt.Before(s.now()) {
|
|
return ErrCodeExpired
|
|
}
|
|
expected := hashVerificationCode(code, target, s.cfg.TokenSecret)
|
|
if expected != record.CodeHash {
|
|
_ = s.codeStore.IncrementVerificationCodeAttempts(ctx, record.ID)
|
|
return ErrCodeInvalid
|
|
}
|
|
return s.codeStore.MarkVerificationCodeConsumed(ctx, record.ID, s.now())
|
|
}
|
|
|
|
func (s *Service) verifyHuman(ctx context.Context, token string) error {
|
|
if !s.cfg.TurnstileRequired {
|
|
return nil
|
|
}
|
|
if strings.TrimSpace(token) == "" {
|
|
return ErrHumanVerificationRequired
|
|
}
|
|
if s.humanVerifier == nil {
|
|
return ErrProviderNotReady
|
|
}
|
|
return s.humanVerifier.Verify(ctx, token)
|
|
}
|
|
|
|
func (s *Service) upsertAndSign(ctx context.Context, user User) (Session, error) {
|
|
user.ID = normalizeUUID(user.ID)
|
|
user.Status = activeStatus(user.Status)
|
|
saved, err := s.store.UpsertUser(ctx, user)
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
now := s.now()
|
|
sessionID := newID()
|
|
token, expiresIn, err := signSessionToken(saved.ID, sessionID, s.cfg.TokenSecret, s.cfg.TokenTTL, now)
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
refreshToken, refreshExpiresIn, err := signSessionRefreshToken(saved.ID, sessionID, s.cfg.TokenSecret, s.cfg.RefreshTokenTTL, now)
|
|
if err != nil {
|
|
return Session{}, err
|
|
}
|
|
deviceSession := deviceSessionFromMetadata(RequestMetadataFromContext(ctx))
|
|
deviceSession.ID = sessionID
|
|
deviceSession.UserID = saved.ID
|
|
deviceSession.CreatedAt = now.UTC()
|
|
deviceSession.LastSeenAt = now.UTC()
|
|
deviceSession.ExpiresAt = now.Add(time.Duration(expiresIn) * time.Second).UTC()
|
|
if err := s.store.CreateDeviceSession(ctx, deviceSession, s.deviceSessionLimit(deviceSession.DeviceType)); err != nil {
|
|
return Session{}, err
|
|
}
|
|
return Session{
|
|
Status: SessionStatusAuthenticated,
|
|
User: saved,
|
|
AccessToken: token,
|
|
ExpiresIn: expiresIn,
|
|
RefreshToken: refreshToken,
|
|
RefreshExpiresIn: refreshExpiresIn,
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) ensureLegacyDeviceSession(ctx context.Context, sessionID string, userID string, payload tokenPayload, now time.Time) error {
|
|
if _, err := s.store.FindDeviceSession(ctx, sessionID); err == nil {
|
|
return nil
|
|
} else if !errors.Is(err, design.ErrNotFound) {
|
|
return err
|
|
}
|
|
if _, err := s.store.FindUserByID(ctx, userID); err != nil {
|
|
return err
|
|
}
|
|
createdAt := time.Unix(payload.Issued, 0).UTC()
|
|
if payload.Issued <= 0 || createdAt.After(now) {
|
|
createdAt = now
|
|
}
|
|
session := deviceSessionFromMetadata(RequestMetadataFromContext(ctx))
|
|
session.ID = sessionID
|
|
session.UserID = userID
|
|
session.CreatedAt = createdAt
|
|
session.LastSeenAt = now
|
|
session.ExpiresAt = time.Unix(payload.Expires, 0).UTC()
|
|
return s.store.CreateDeviceSession(ctx, session, s.deviceSessionLimit(session.DeviceType))
|
|
}
|
|
|
|
func (s *Service) touchDeviceSession(ctx context.Context, session DeviceSession, now time.Time) error {
|
|
metadata := RequestMetadataFromContext(ctx)
|
|
if metadata.UserAgent != "" || metadata.Platform != "" || metadata.Mobile != "" {
|
|
parsed := deviceSessionFromMetadata(metadata)
|
|
session.DeviceType = parsed.DeviceType
|
|
session.System = parsed.System
|
|
session.Browser = parsed.Browser
|
|
session.Client = parsed.Client
|
|
session.UserAgent = parsed.UserAgent
|
|
}
|
|
session.LastSeenAt = now
|
|
return s.store.TouchDeviceSession(ctx, session)
|
|
}
|
|
|
|
func (s *Service) requireActiveDeviceSession(ctx context.Context, userID string, sessionID string) error {
|
|
if strings.TrimSpace(sessionID) == "" {
|
|
return fmt.Errorf("%w: current session is required", ErrInvalidCredentials)
|
|
}
|
|
session, err := s.store.FindDeviceSession(ctx, sessionID)
|
|
if err != nil || session.UserID != userID || session.RevokedAt != nil || !session.ExpiresAt.After(s.now().UTC()) {
|
|
return fmt.Errorf("%w: current session is invalid", ErrInvalidCredentials)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Service) deviceSessionLimit(deviceType string) int64 {
|
|
if deviceType == "mobile" {
|
|
return s.cfg.DeviceLimits.Mobile
|
|
}
|
|
return s.cfg.DeviceLimits.Desktop
|
|
}
|
|
|
|
func legacyDeviceSessionID(token string) string {
|
|
digest := sha256.Sum256([]byte(strings.TrimSpace(token)))
|
|
return "legacy:" + base64.RawURLEncoding.EncodeToString(digest[:])
|
|
}
|
|
|
|
func (s *Service) requireRegion(region string) error {
|
|
if normalizeRegion(s.cfg.Region) != region {
|
|
return fmt.Errorf("%w: current region is %s", ErrInvalidRegion, s.cfg.Region)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeRegion(region string) string {
|
|
region = strings.ToLower(strings.TrimSpace(region))
|
|
switch region {
|
|
case RegionChina, "cn", "mainland":
|
|
return RegionChina
|
|
case RegionGlobal, "foreign", "oversea", "overseas", "intl", "international":
|
|
return RegionGlobal
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func normalizePurpose(purpose string) string {
|
|
purpose = strings.ToLower(strings.TrimSpace(purpose))
|
|
switch purpose {
|
|
case PurposeWechatBind:
|
|
return PurposeWechatBind
|
|
default:
|
|
return PurposeLogin
|
|
}
|
|
}
|
|
|
|
func normalizeCountryCode(countryCode string) string {
|
|
countryCode = strings.TrimSpace(countryCode)
|
|
if countryCode == "" {
|
|
return defaultCountryCode
|
|
}
|
|
if !strings.HasPrefix(countryCode, "+") {
|
|
countryCode = "+" + countryCode
|
|
}
|
|
return countryCode
|
|
}
|
|
|
|
var nonDigits = regexp.MustCompile(`\D+`)
|
|
|
|
func normalizePhone(phone string) string {
|
|
return nonDigits.ReplaceAllString(strings.TrimSpace(phone), "")
|
|
}
|
|
|
|
func normalizeEmail(email string) string {
|
|
return strings.ToLower(strings.TrimSpace(email))
|
|
}
|
|
|
|
func validAvatarURL(value string) bool {
|
|
if strings.HasPrefix(value, "/memory-assets/") {
|
|
return true
|
|
}
|
|
parsed, err := url.Parse(value)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return parsed.Scheme == "http" || parsed.Scheme == "https"
|
|
}
|
|
|
|
func phoneTarget(countryCode string, phone string) string {
|
|
return normalizeCountryCode(countryCode) + ":" + normalizePhone(phone)
|
|
}
|
|
|
|
func activeStatus(status string) string {
|
|
status = strings.TrimSpace(status)
|
|
if status == "" {
|
|
return "active"
|
|
}
|
|
return status
|
|
}
|
|
|
|
func normalizeUUID(id string) string {
|
|
id = strings.TrimSpace(id)
|
|
if _, err := uuid.Parse(id); err == nil {
|
|
return id
|
|
}
|
|
return newID()
|
|
}
|
|
|
|
func newID() string {
|
|
return uuid.NewString()
|
|
}
|
|
|
|
func randomDigits(length int) (string, error) {
|
|
if length <= 0 {
|
|
length = 6
|
|
}
|
|
var builder strings.Builder
|
|
for i := 0; i < length; i++ {
|
|
n, err := rand.Int(rand.Reader, big.NewInt(10))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
builder.WriteByte(byte('0' + n.Int64()))
|
|
}
|
|
return builder.String(), nil
|
|
}
|