apple sign in

This commit is contained in:
Yared Yemane 2026-06-01 01:02:28 -07:00
parent c00ab684c5
commit 632371c3d0
15 changed files with 591 additions and 10 deletions

View File

@ -0,0 +1,5 @@
DROP INDEX IF EXISTS idx_users_apple_id;
ALTER TABLE users
DROP COLUMN IF EXISTS apple_id,
DROP COLUMN IF EXISTS apple_email_verified;

View File

@ -0,0 +1,7 @@
ALTER TABLE users
ADD COLUMN IF NOT EXISTS apple_id VARCHAR(255),
ADD COLUMN IF NOT EXISTS apple_email_verified BOOLEAN DEFAULT FALSE;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_apple_id
ON users (apple_id)
WHERE apple_id IS NOT NULL;

View File

@ -23,6 +23,30 @@ SET
updated_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- name: CreateAppleUser :one
INSERT INTO users (
first_name,
last_name,
email,
apple_id,
apple_email_verified,
role,
status,
email_verified
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8
)
RETURNING *;
-- name: LinkAppleAccount :exec
UPDATE users
SET
apple_id = $2,
apple_email_verified = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1;
-- name: IsUserPending :one
SELECT
CASE WHEN status = 'PENDING' THEN true ELSE false END AS is_pending
@ -156,6 +180,10 @@ SELECT *
FROM users
WHERE google_id = $1;
-- name: GetUserByAppleID :one
SELECT *
FROM users
WHERE apple_id = $1;
-- name: GetAllUsers :many
SELECT

View File

@ -571,6 +571,8 @@ type User struct {
DeletionRequestedAt pgtype.Timestamptz `json:"deletion_requested_at"`
DeletionScheduledAt pgtype.Timestamptz `json:"deletion_scheduled_at"`
DeletionCancelledAt pgtype.Timestamptz `json:"deletion_cancelled_at"`
AppleID pgtype.Text `json:"apple_id"`
AppleEmailVerified pgtype.Bool `json:"apple_email_verified"`
}
type UserAudioResponse struct {

View File

@ -86,6 +86,89 @@ func (q *Queries) CheckPhoneEmailExist(ctx context.Context, arg CheckPhoneEmailE
return i, err
}
const CreateAppleUser = `-- name: CreateAppleUser :one
INSERT INTO users (
first_name,
last_name,
email,
apple_id,
apple_email_verified,
role,
status,
email_verified
)
VALUES (
$1, $2, $3, $4, $5, $6, $7, $8
)
RETURNING id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at, apple_id, apple_email_verified
`
type CreateAppleUserParams struct {
FirstName pgtype.Text `json:"first_name"`
LastName pgtype.Text `json:"last_name"`
Email pgtype.Text `json:"email"`
AppleID pgtype.Text `json:"apple_id"`
AppleEmailVerified pgtype.Bool `json:"apple_email_verified"`
Role string `json:"role"`
Status string `json:"status"`
EmailVerified bool `json:"email_verified"`
}
func (q *Queries) CreateAppleUser(ctx context.Context, arg CreateAppleUserParams) (User, error) {
row := q.db.QueryRow(ctx, CreateAppleUser,
arg.FirstName,
arg.LastName,
arg.Email,
arg.AppleID,
arg.AppleEmailVerified,
arg.Role,
arg.Status,
arg.EmailVerified,
)
var i User
err := row.Scan(
&i.ID,
&i.FirstName,
&i.LastName,
&i.Gender,
&i.BirthDay,
&i.Email,
&i.PhoneNumber,
&i.Role,
&i.Password,
&i.EducationLevel,
&i.Country,
&i.Region,
&i.KnowledgeLevel,
&i.NickName,
&i.Occupation,
&i.LearningGoal,
&i.LanguageGoal,
&i.LanguageChallange,
&i.FavouriteTopic,
&i.InitialAssessmentCompleted,
&i.EmailVerified,
&i.PhoneVerified,
&i.Status,
&i.LastLogin,
&i.ProfileCompleted,
&i.ProfilePictureUrl,
&i.PreferredLanguage,
&i.CreatedAt,
&i.UpdatedAt,
&i.AgeGroup,
&i.GoogleID,
&i.GoogleEmailVerified,
&i.ProfileCompletionPercentage,
&i.DeletionRequestedAt,
&i.DeletionScheduledAt,
&i.DeletionCancelledAt,
&i.AppleID,
&i.AppleEmailVerified,
)
return i, err
}
const CreateGoogleUser = `-- name: CreateGoogleUser :one
INSERT INTO users (
first_name,
@ -101,7 +184,7 @@ INSERT INTO users (
VALUES (
$1, $2, $3, $4, $5, $6, $7, true, $8
)
RETURNING id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at
RETURNING id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at, apple_id, apple_email_verified
`
type CreateGoogleUserParams struct {
@ -164,6 +247,8 @@ func (q *Queries) CreateGoogleUser(ctx context.Context, arg CreateGoogleUserPara
&i.DeletionRequestedAt,
&i.DeletionScheduledAt,
&i.DeletionCancelledAt,
&i.AppleID,
&i.AppleEmailVerified,
)
return i, err
}
@ -621,6 +706,58 @@ func (q *Queries) GetTotalUsers(ctx context.Context, role string) (int64, error)
return count, err
}
const GetUserByAppleID = `-- name: GetUserByAppleID :one
SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at, apple_id, apple_email_verified
FROM users
WHERE apple_id = $1
`
func (q *Queries) GetUserByAppleID(ctx context.Context, appleID pgtype.Text) (User, error) {
row := q.db.QueryRow(ctx, GetUserByAppleID, appleID)
var i User
err := row.Scan(
&i.ID,
&i.FirstName,
&i.LastName,
&i.Gender,
&i.BirthDay,
&i.Email,
&i.PhoneNumber,
&i.Role,
&i.Password,
&i.EducationLevel,
&i.Country,
&i.Region,
&i.KnowledgeLevel,
&i.NickName,
&i.Occupation,
&i.LearningGoal,
&i.LanguageGoal,
&i.LanguageChallange,
&i.FavouriteTopic,
&i.InitialAssessmentCompleted,
&i.EmailVerified,
&i.PhoneVerified,
&i.Status,
&i.LastLogin,
&i.ProfileCompleted,
&i.ProfilePictureUrl,
&i.PreferredLanguage,
&i.CreatedAt,
&i.UpdatedAt,
&i.AgeGroup,
&i.GoogleID,
&i.GoogleEmailVerified,
&i.ProfileCompletionPercentage,
&i.DeletionRequestedAt,
&i.DeletionScheduledAt,
&i.DeletionCancelledAt,
&i.AppleID,
&i.AppleEmailVerified,
)
return i, err
}
const GetUserByEmailPhone = `-- name: GetUserByEmailPhone :one
@ -768,7 +905,7 @@ func (q *Queries) GetUserByEmailPhone(ctx context.Context, arg GetUserByEmailPho
}
const GetUserByGoogleID = `-- name: GetUserByGoogleID :one
SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at
SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at, apple_id, apple_email_verified
FROM users
WHERE google_id = $1
`
@ -813,12 +950,14 @@ func (q *Queries) GetUserByGoogleID(ctx context.Context, googleID pgtype.Text) (
&i.DeletionRequestedAt,
&i.DeletionScheduledAt,
&i.DeletionCancelledAt,
&i.AppleID,
&i.AppleEmailVerified,
)
return i, err
}
const GetUserByID = `-- name: GetUserByID :one
SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at
SELECT id, first_name, last_name, gender, birth_day, email, phone_number, role, password, education_level, country, region, knowledge_level, nick_name, occupation, learning_goal, language_goal, language_challange, favourite_topic, initial_assessment_completed, email_verified, phone_verified, status, last_login, profile_completed, profile_picture_url, preferred_language, created_at, updated_at, age_group, google_id, google_email_verified, profile_completion_percentage, deletion_requested_at, deletion_scheduled_at, deletion_cancelled_at, apple_id, apple_email_verified
FROM users
WHERE id = $1
`
@ -863,6 +1002,8 @@ func (q *Queries) GetUserByID(ctx context.Context, id int64) (User, error) {
&i.DeletionRequestedAt,
&i.DeletionScheduledAt,
&i.DeletionCancelledAt,
&i.AppleID,
&i.AppleEmailVerified,
)
return i, err
}
@ -930,6 +1071,26 @@ func (q *Queries) IsUserPending(ctx context.Context, id int64) (bool, error) {
return is_pending, err
}
const LinkAppleAccount = `-- name: LinkAppleAccount :exec
UPDATE users
SET
apple_id = $2,
apple_email_verified = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`
type LinkAppleAccountParams struct {
ID int64 `json:"id"`
AppleID pgtype.Text `json:"apple_id"`
AppleEmailVerified pgtype.Bool `json:"apple_email_verified"`
}
func (q *Queries) LinkAppleAccount(ctx context.Context, arg LinkAppleAccountParams) error {
_, err := q.db.Exec(ctx, LinkAppleAccount, arg.ID, arg.AppleID, arg.AppleEmailVerified)
return err
}
const LinkGoogleAccount = `-- name: LinkGoogleAccount :exec
UPDATE users
SET

4
go.mod
View File

@ -29,7 +29,7 @@ require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.29.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.51.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // indirect
github.com/MicahParks/keyfunc v1.9.0 // direct
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cncf/xds/go v0.0.0-20250501225837-2ac532fd4443 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@ -40,7 +40,7 @@ require (
github.com/go-jose/go-jose/v4 v4.1.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // direct
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect

View File

@ -99,6 +99,8 @@ type Config struct {
GoogleOAuthClientID string
GoogleOAuthClientSecret string
GoogleOAuthRedirectURL string
// AppleSignInClientIDs is a comma-separated list of allowed "aud" values (iOS bundle ID, Services ID, etc.).
AppleSignInClientIDs string
AFROSMSConfig AFROSMSConfig `mapstructure:"afro_sms_config"`
Vimeo VimeoConfig `mapstructure:"vimeo_config"`
CloudConvert CloudConvertConfig `mapstructure:"cloudconvert_config"`
@ -174,6 +176,7 @@ func (c *Config) loadEnv() error {
c.GoogleOAuthClientID = os.Getenv("GOOGLE_OAUTH_CLIENT_ID")
c.GoogleOAuthClientSecret = os.Getenv("GOOGLE_OAUTH_CLIENT_SECRET")
c.GoogleOAuthRedirectURL = os.Getenv("GOOGLE_OAUTH_REDIRECT_URL")
c.AppleSignInClientIDs = os.Getenv("APPLE_SIGN_IN_CLIENT_IDS")
c.APP_VERSION = os.Getenv("APP_VERSION")

View File

@ -13,6 +13,15 @@ type GoogleUser struct {
Picture string
}
// AppleUser is populated from a validated Sign in with Apple identity token and optional client-supplied profile fields.
type AppleUser struct {
ID string
Email string
VerifiedEmail bool
GivenName string
FamilyName string
}
type LoginSuccess struct {
UserId int64
Role Role

View File

@ -15,6 +15,8 @@ type ProfileCompletionStatus struct {
type UserStore interface {
CreateGoogleUser(ctx context.Context, gUser domain.GoogleUser) (domain.User, error)
LinkGoogleAccount(ctx context.Context, userID int64, googleID string) error
CreateAppleUser(ctx context.Context, aUser domain.AppleUser) (domain.User, error)
LinkAppleAccount(ctx context.Context, userID int64, appleID string, emailVerified bool) error
GetProfileCompletionStatus(ctx context.Context, userId int64) (ProfileCompletionStatus, error)
UpdateUserStatus(ctx context.Context, req domain.UpdateUserStatusReq) error
// GetCorrectOptionForQuestion(
@ -45,6 +47,10 @@ type UserStore interface {
ctx context.Context,
googleId string,
) (domain.User, error)
GetUserByAppleID(
ctx context.Context,
appleID string,
) (domain.User, error)
GetUserByID(
ctx context.Context,
id int64,

View File

@ -38,6 +38,11 @@ func (s *Store) LmsUserHasLessonProgress(ctx context.Context, userID, lessonID i
return s.queries.UserHasLessonProgress(ctx, dbgen.UserHasLessonProgressParams{UserID: userID, LessonID: lessonID})
}
// LmsCountPublishedPracticesInLesson returns how many published practices are attached to a lesson.
func (s *Store) LmsCountPublishedPracticesInLesson(ctx context.Context, lessonID int64) (int32, error) {
return s.queries.CountPublishedPracticesInLesson(ctx, toPgInt8(&lessonID))
}
// LmsUserPracticeProgressInLesson returns published practice completion counts scoped to a lesson.
func (s *Store) LmsUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) {
lessonIDPG := toPgInt8(&lessonID)

View File

@ -54,6 +54,53 @@ func (s *Store) LinkGoogleAccount(
})
}
func (s *Store) LinkAppleAccount(
ctx context.Context,
userID int64,
appleID string,
emailVerified bool,
) error {
return s.queries.LinkAppleAccount(ctx, dbgen.LinkAppleAccountParams{
ID: userID,
AppleID: pgtype.Text{String: appleID, Valid: true},
AppleEmailVerified: pgtype.Bool{Bool: emailVerified, Valid: true},
})
}
func (s *Store) CreateAppleUser(
ctx context.Context,
aUser domain.AppleUser,
) (domain.User, error) {
res, err := s.queries.CreateAppleUser(ctx, dbgen.CreateAppleUserParams{
FirstName: pgtype.Text{String: aUser.GivenName, Valid: aUser.GivenName != ""},
LastName: pgtype.Text{String: aUser.FamilyName, Valid: aUser.FamilyName != ""},
Email: pgtype.Text{String: aUser.Email, Valid: aUser.Email != ""},
AppleID: pgtype.Text{String: aUser.ID, Valid: true},
AppleEmailVerified: pgtype.Bool{Bool: aUser.VerifiedEmail, Valid: true},
Role: string(domain.RoleStudent),
Status: string(domain.UserStatusActive),
EmailVerified: aUser.VerifiedEmail,
})
if err != nil {
return domain.User{}, err
}
return mapDBUser(res, nil, nil), nil
}
func (s *Store) GetUserByAppleID(
ctx context.Context,
appleID string,
) (domain.User, error) {
u, err := s.queries.GetUserByAppleID(ctx, pgtype.Text{String: appleID, Valid: appleID != ""})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return domain.User{}, domain.ErrUserNotFound
}
return domain.User{}, err
}
return mapDBUser(u, nil, nil), nil
}
func (s *Store) CreateGoogleUser(
ctx context.Context,
gUser domain.GoogleUser,

View File

@ -0,0 +1,231 @@
package authentication
import (
"Yimaru-Backend/internal/domain"
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/MicahParks/keyfunc"
"github.com/golang-jwt/jwt/v4"
)
const appleJWKSURL = "https://appleid.apple.com/auth/keys"
var (
appleJWKS *keyfunc.JWKS
appleJWKSOnce sync.Once
appleJWKSErr error
)
var ErrAppleSignInNotConfigured = errors.New("apple sign in is not configured")
func appleAllowedClientIDs(clientIDs string) []string {
parts := strings.Split(clientIDs, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if id := strings.TrimSpace(p); id != "" {
out = append(out, id)
}
}
return out
}
func getAppleJWKS() (*keyfunc.JWKS, error) {
appleJWKSOnce.Do(func() {
appleJWKS, appleJWKSErr = keyfunc.Get(appleJWKSURL, keyfunc.Options{
RefreshInterval: time.Hour,
RefreshTimeout: 10 * time.Second,
RefreshUnknownKID: true,
})
})
return appleJWKS, appleJWKSErr
}
type appleIdentityClaims struct {
jwt.RegisteredClaims
Email string `json:"email"`
EmailVerified any `json:"email_verified"`
IsPrivateEmail any `json:"is_private_email"`
}
func claimEmailVerified(v any) bool {
switch t := v.(type) {
case bool:
return t
case string:
return strings.EqualFold(t, "true")
default:
return false
}
}
func (s *Service) ValidateAppleIdentityToken(
ctx context.Context,
idToken string,
allowedClientIDs []string,
) (domain.AppleUser, error) {
if len(allowedClientIDs) == 0 {
return domain.AppleUser{}, ErrAppleSignInNotConfigured
}
if idToken == "" {
return domain.AppleUser{}, errors.New("missing apple identity token")
}
jwks, err := getAppleJWKS()
if err != nil {
return domain.AppleUser{}, fmt.Errorf("failed to load apple jwks: %w", err)
}
token, err := jwt.ParseWithClaims(idToken, &appleIdentityClaims{}, jwks.Keyfunc)
if err != nil {
return domain.AppleUser{}, fmt.Errorf("invalid apple identity token: %w", err)
}
if !token.Valid {
return domain.AppleUser{}, errors.New("invalid apple identity token")
}
claims, ok := token.Claims.(*appleIdentityClaims)
if !ok {
return domain.AppleUser{}, errors.New("invalid apple token claims")
}
if claims.Issuer != "https://appleid.apple.com" {
return domain.AppleUser{}, errors.New("invalid apple token issuer")
}
audOK := false
for _, aud := range claims.Audience {
for _, allowed := range allowedClientIDs {
if aud == allowed {
audOK = true
break
}
}
if audOK {
break
}
}
if !audOK {
return domain.AppleUser{}, errors.New("apple token audience mismatch")
}
if claims.Subject == "" {
return domain.AppleUser{}, errors.New("apple token missing subject")
}
return domain.AppleUser{
ID: claims.Subject,
Email: strings.TrimSpace(claims.Email),
VerifiedEmail: claimEmailVerified(claims.EmailVerified),
}, nil
}
// LoginWithAppleMobile validates the identity token and signs the user in (iOS / Android / web credential).
func (s *Service) LoginWithAppleMobile(
ctx context.Context,
idToken string,
allowedClientIDs string,
profile domain.AppleUser,
) (domain.LoginSuccess, error) {
aUser, err := s.ValidateAppleIdentityToken(ctx, idToken, appleAllowedClientIDs(allowedClientIDs))
if err != nil {
return domain.LoginSuccess{}, err
}
if aUser.Email == "" {
aUser.Email = strings.TrimSpace(profile.Email)
}
if aUser.GivenName == "" {
aUser.GivenName = strings.TrimSpace(profile.GivenName)
}
if aUser.FamilyName == "" {
aUser.FamilyName = strings.TrimSpace(profile.FamilyName)
}
if !aUser.VerifiedEmail && profile.VerifiedEmail {
aUser.VerifiedEmail = true
}
return s.LoginWithApple(ctx, aUser)
}
func (s *Service) LoginWithApple(
ctx context.Context,
aUser domain.AppleUser,
) (domain.LoginSuccess, error) {
if aUser.ID == "" {
return domain.LoginSuccess{}, errors.New("missing apple user id")
}
var user domain.User
var err error
user, err = s.userStore.GetUserByAppleID(ctx, aUser.ID)
if err != nil {
if !errors.Is(err, domain.ErrUserNotFound) {
return domain.LoginSuccess{}, err
}
if aUser.Email == "" {
return domain.LoginSuccess{}, errors.New("email is required on first sign in with apple")
}
user, err = s.userStore.GetUserByEmailPhone(ctx, aUser.Email, "")
if err != nil {
if !errors.Is(err, domain.ErrUserNotFound) {
return domain.LoginSuccess{}, err
}
user, err = s.userStore.CreateAppleUser(ctx, aUser)
if err != nil {
return domain.LoginSuccess{}, err
}
} else {
if err := s.userStore.LinkAppleAccount(ctx, user.ID, aUser.ID, aUser.VerifiedEmail); err != nil {
return domain.LoginSuccess{}, err
}
}
}
if user.Status == domain.UserStatusPending {
return domain.LoginSuccess{}, domain.ErrUserNotVerified
}
if user.Status == domain.UserStatusSuspended {
return domain.LoginSuccess{}, ErrUserSuspended
}
oldToken, err := s.tokenStore.GetRefreshTokenByUserID(ctx, user.ID)
if err != nil {
if !errors.Is(err, ErrRefreshTokenNotFound) {
return domain.LoginSuccess{}, err
}
} else if !oldToken.Revoked {
if err := s.tokenStore.RevokeRefreshToken(ctx, oldToken.Token); err != nil {
return domain.LoginSuccess{}, err
}
}
refreshToken, err := generateRefreshToken()
if err != nil {
return domain.LoginSuccess{}, err
}
if err := s.tokenStore.CreateRefreshToken(ctx, domain.RefreshToken{
Token: refreshToken,
UserID: user.ID,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(time.Duration(s.RefreshExpiry) * time.Second),
}); err != nil {
return domain.LoginSuccess{}, err
}
return domain.LoginSuccess{
UserId: user.ID,
Role: user.Role,
RfToken: refreshToken,
}, nil
}

View File

@ -314,12 +314,16 @@ func (s *Service) lmsModuleProgress(ctx context.Context, userID, moduleID int64)
if err != nil {
return 0, false, 0, 0, err
}
if len(lessons) == 0 {
return 0, false, 0, 0, nil
}
var doneLessons int32
for _, lesson := range lessons {
practiceCount, err := s.store.LmsCountPublishedPracticesInLesson(ctx, lesson.ID)
if err != nil {
return 0, false, 0, 0, err
}
if practiceCount == 0 {
continue
}
total++
lessonFraction, _, _, _, err := s.lmsLessonProgress(ctx, userID, lesson.ID)
if err != nil {
return 0, false, 0, 0, err
@ -328,7 +332,9 @@ func (s *Service) lmsModuleProgress(ctx context.Context, userID, moduleID int64)
doneLessons++
}
}
total = int32(len(lessons))
if total == 0 {
return 0, false, 0, 0, nil
}
fraction = float64(doneLessons) / float64(total)
return fraction, fraction >= 1, doneLessons, total, nil
}

View File

@ -87,6 +87,76 @@ func (h *Handler) GoogleAndroidLogin(c *fiber.Ctx) error {
})
}
// AppleLogin godoc
// @Summary Login via Sign in with Apple identity token
// @Description Validates an Apple identity token (iOS, Android, or web). On first sign-in, include email and name if Apple only returns them to the client once.
// @Tags auth
// @Accept json
// @Produce json
// @Param body body object true "Apple login payload"
// @Router /api/v1/auth/apple [post]
func (h *Handler) AppleLogin(c *fiber.Ctx) error {
var req struct {
IDToken string `json:"id_token" validate:"required"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
}
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(domain.ErrorResponse{
Message: "Invalid request body",
Error: err.Error(),
})
}
if h.Cfg.AppleSignInClientIDs == "" {
return c.Status(fiber.StatusServiceUnavailable).JSON(domain.ErrorResponse{
Message: "Apple Sign In is not configured",
Error: "APPLE_SIGN_IN_CLIENT_IDS is not set",
})
}
loginRes, err := h.authSvc.LoginWithAppleMobile(c.Context(), req.IDToken, h.Cfg.AppleSignInClientIDs, domain.AppleUser{
Email: req.Email,
GivenName: req.FirstName,
FamilyName: req.LastName,
})
if err != nil {
h.mongoLoggerSvc.Error("Apple login failed",
zap.Error(err),
zap.Int("id_token_length", len(req.IDToken)),
)
return c.Status(fiber.StatusUnauthorized).JSON(domain.ErrorResponse{
Message: "Apple login failed",
Error: err.Error(),
})
}
accessToken, err := jwtutil.CreateJwt(
loginRes.UserId,
loginRes.Role,
h.jwtConfig.JwtAccessKey,
h.jwtConfig.JwtAccessExpiry,
)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(domain.ErrorResponse{
Message: "Token generation failed",
Error: err.Error(),
})
}
return c.Status(fiber.StatusOK).JSON(domain.Response{
Message: "Login successful",
Data: loginUserRes{
AccessToken: accessToken,
RefreshToken: loginRes.RfToken,
Role: string(loginRes.Role),
UserID: loginRes.UserId,
},
})
}
// GoogleLogin godoc
// @Summary Google login redirect
// @Tags auth

View File

@ -291,6 +291,7 @@ func (a *App) initAppRoutes() {
groupV1.Post("/auth/google/android", h.GoogleAndroidLogin)
groupV1.Get("/auth/google/login", h.GoogleLogin)
groupV1.Get("/auth/google/callback", h.GoogleCallback)
groupV1.Post("/auth/apple", h.AppleLogin)
groupV1.Post("/auth/customer-login", h.LoginUser)
groupV1.Post("/auth/admin-login", h.LoginAdmin)
groupV1.Post("/auth/super-login", h.LoginSuper)