package logic import ( "context" authentity "spark-server/internal/module/auth/entity" deviceentity "spark-server/internal/module/device/entity" devicelogic "spark-server/internal/module/device/logic" userentity "spark-server/internal/module/user/entity" "spark-server/internal/pkg/errs" "spark-server/internal/pkg/i18n" "spark-server/internal/pkg/reqctx" "spark-server/internal/pkg/validate" ) // Verifier checks email OTP and records the login device. The user module owns the implementation. type Verifier interface { AuthenticateEmail(ctx context.Context, email, verifyCode string, device *userentity.LoginDevice) (*userentity.User, error) } // PasswordVerifier checks email+password (Health / Sleephealth). Optional. type PasswordVerifier interface { VerifyPassword(ctx context.Context, email, password string) (*userentity.User, error) } // DeviceRegistrar resolves physical device_id from idfv / android_id. type DeviceRegistrar interface { Register(ctx context.Context, in devicelogic.RegisterInput) (uint64, error) } // LoginInput is the HTTP login body (OTP). type LoginInput struct { Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"` VerifyCode string `json:"verify_code" validate:"required" label:"field.verify_code" errmsg:"required:validate.login.code_required"` AppID uint8 `json:"app_id" validate:"required,min=1" label:"field.app_id" errmsg:"required:validate.login.app_id_required"` DeviceType uint8 `json:"device_type" validate:"omitempty,min=1,max=6" label:"field.device_type" errmsg:"min:validate.login.device_type_invalid;max:validate.login.device_type_invalid"` Platform uint8 `json:"platform" validate:"omitempty,min=1,max=6" label:"field.device_type" errmsg:"min:validate.login.device_type_invalid;max:validate.login.device_type_invalid"` DeviceName string `json:"device_name" validate:"omitempty,max=128" label:"field.device_name"` IDFV string `json:"idfv" validate:"omitempty,max=36" label:"field.idfv"` AndroidID string `json:"android_id" validate:"omitempty,max=20" label:"field.android_id"` } // PasswordLoginInput is POST /v1/auth/password-login body. type PasswordLoginInput struct { Email string `json:"email" validate:"required,email" label:"field.email" errmsg:"required:validate.login.email_required;email:validate.login.email_invalid"` Password string `json:"password" validate:"required,min=6,max=72" label:"field.password" errmsg:"required:validate.login.password_required;min:validate.login.password_min"` AppID uint8 `json:"app_id" validate:"required,min=1" label:"field.app_id" errmsg:"required:validate.login.app_id_required"` DeviceType uint8 `json:"device_type" validate:"omitempty,min=1,max=6" label:"field.device_type" errmsg:"min:validate.login.device_type_invalid;max:validate.login.device_type_invalid"` Platform uint8 `json:"platform" validate:"omitempty,min=1,max=6" label:"field.device_type" errmsg:"min:validate.login.device_type_invalid;max:validate.login.device_type_invalid"` DeviceName string `json:"device_name" validate:"omitempty,max=128" label:"field.device_name"` IDFV string `json:"idfv" validate:"omitempty,max=36" label:"field.idfv"` AndroidID string `json:"android_id" validate:"omitempty,max=20" label:"field.android_id"` } type RefreshInput struct { RefreshToken string `json:"refresh_token" validate:"required" label:"field.refresh_token" errmsg:"required:validate.login.refresh_required"` } // SessionKicker drops the IM connection for one device after HTTP logout. type SessionKicker interface { KickDevice(userID int64, deviceID uint64) } // LoginService is the application login use-case. type LoginService struct { Issuer Issuer Users Verifier Passwords PasswordVerifier Devices DeviceRegistrar Sessions SessionKicker } func NewLoginService(issuer Issuer, users Verifier) *LoginService { return &LoginService{Issuer: issuer, Users: users} } func (s *LoginService) Login(ctx context.Context, in *LoginInput) (*authentity.IssuedToken, error) { if s == nil || s.Issuer == nil || s.Users == nil || s.Devices == nil || in == nil { return nil, errs.ErrUnauthorized } if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { return nil, err } if !deviceentity.AppID(in.AppID).Valid() { return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginAppIDInvalid, nil)) } dtype := loginResolvedDeviceType(in.DeviceType, in.Platform) if !dtype.Valid() { return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginDeviceTypeRequired, nil)) } deviceID, err := s.Devices.Register(ctx, devicelogic.RegisterInput{ DeviceType: dtype, DeviceName: in.DeviceName, IDFV: in.IDFV, AndroidID: in.AndroidID, ClientIP: reqctx.RealIP(ctx), }) if err != nil { return nil, err } ld := loginDeviceFrom(ctx, in.AppID, in.DeviceName, in.IDFV, in.AndroidID, dtype, deviceID) u, err := s.Users.AuthenticateEmail(ctx, in.Email, in.VerifyCode, ld) if err != nil { return nil, err } return s.issueForUser(ctx, u, deviceID, uint32(in.AppID)) } func (s *LoginService) PasswordLogin(ctx context.Context, in *PasswordLoginInput) (*authentity.IssuedToken, error) { if s == nil || s.Issuer == nil || s.Passwords == nil || s.Devices == nil || in == nil { return nil, errs.ErrUnauthorized } if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { return nil, err } if !deviceentity.AppID(in.AppID).Valid() { return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginAppIDInvalid, nil)) } dtype := loginResolvedDeviceType(in.DeviceType, in.Platform) if !dtype.Valid() { return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeyLoginDeviceTypeRequired, nil)) } u, err := s.Passwords.VerifyPassword(ctx, in.Email, in.Password) if err != nil { return nil, err } deviceID, err := s.Devices.Register(ctx, devicelogic.RegisterInput{ DeviceType: dtype, DeviceName: in.DeviceName, IDFV: in.IDFV, AndroidID: in.AndroidID, ClientIP: reqctx.RealIP(ctx), }) if err != nil { return nil, err } return s.issueForUser(ctx, u, deviceID, uint32(in.AppID)) } func (s *LoginService) issueForUser(ctx context.Context, u *userentity.User, deviceID uint64, appID uint32) (*authentity.IssuedToken, error) { if u == nil || u.ID <= 0 { return nil, errs.ErrUnauthorized } tok, err := s.Issuer.Issue(ctx, u.ID, deviceID, appID) if err != nil { return nil, err } tok.SparkID = u.SparkID return tok, nil } func (s *LoginService) Refresh(ctx context.Context, in RefreshInput) (*authentity.IssuedToken, error) { if s == nil || s.Issuer == nil { return nil, errs.ErrUnauthorized } if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil { return nil, err } return s.Issuer.Refresh(ctx, in.RefreshToken) } func (s *LoginService) Logout(ctx context.Context) error { if s == nil || s.Issuer == nil { return errs.ErrUnauthorized } rc := reqctx.From(ctx) if rc == nil || rc.UserID <= 0 || rc.DeviceID == 0 || rc.AppID == 0 { return errs.ErrUnauthorized } if err := s.Issuer.Logout(ctx, rc.UserID, rc.DeviceID, rc.AppID, rc.JTI, rc.TokenExpires); err != nil { return err } if s.Sessions != nil { s.Sessions.KickDevice(rc.UserID, rc.DeviceID) } return nil } func loginResolvedDeviceType(deviceType, platform uint8) deviceentity.DeviceType { if deviceType >= 1 { return deviceentity.DeviceTypeFromWire(deviceType) } if platform >= 1 { return deviceentity.DeviceTypeFromWire(platform) } return deviceentity.TypeUnknown } func loginDeviceFrom(ctx context.Context, appID uint8, deviceName, idfv, androidID string, dtype deviceentity.DeviceType, deviceID uint64) *userentity.LoginDevice { d := &userentity.LoginDevice{ DeviceID: deviceID, AppID: appID, DeviceType: dtype.PlatformName(), DeviceName: deviceName, IDFV: idfv, AndroidID: androidID, RealIP: reqctx.RealIP(ctx), Language: reqctx.Language(ctx), } if rc := reqctx.From(ctx); rc != nil { d.AppVersion = rc.AppVersion } return d }