From 632371c3d0ab09cbaf157bbefe9cd3f81d67b8ad Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Mon, 1 Jun 2026 01:02:28 -0700 Subject: [PATCH] apple sign in --- db/migrations/000076_apple_sign_in.down.sql | 5 + db/migrations/000076_apple_sign_in.up.sql | 7 + db/query/user.sql | 28 +++ gen/db/models.go | 2 + gen/db/user.sql.go | 167 +++++++++++++- go.mod | 4 +- internal/config/config.go | 3 + internal/domain/auth.go | 9 + internal/ports/user.go | 6 + internal/repository/lms_access.go | 5 + internal/repository/user.go | 47 ++++ internal/services/authentication/apple.go | 231 +++++++++++++++++++ internal/services/lmsprogress/service.go | 16 +- internal/web_server/handlers/auth_handler.go | 70 ++++++ internal/web_server/routes.go | 1 + 15 files changed, 591 insertions(+), 10 deletions(-) create mode 100644 db/migrations/000076_apple_sign_in.down.sql create mode 100644 db/migrations/000076_apple_sign_in.up.sql create mode 100644 internal/services/authentication/apple.go diff --git a/db/migrations/000076_apple_sign_in.down.sql b/db/migrations/000076_apple_sign_in.down.sql new file mode 100644 index 0000000..26d5426 --- /dev/null +++ b/db/migrations/000076_apple_sign_in.down.sql @@ -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; diff --git a/db/migrations/000076_apple_sign_in.up.sql b/db/migrations/000076_apple_sign_in.up.sql new file mode 100644 index 0000000..30f3f55 --- /dev/null +++ b/db/migrations/000076_apple_sign_in.up.sql @@ -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; diff --git a/db/query/user.sql b/db/query/user.sql index e18220f..2bed7dc 100644 --- a/db/query/user.sql +++ b/db/query/user.sql @@ -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 diff --git a/gen/db/models.go b/gen/db/models.go index 8651f3b..f4fc234 100644 --- a/gen/db/models.go +++ b/gen/db/models.go @@ -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 { diff --git a/gen/db/user.sql.go b/gen/db/user.sql.go index 95f6cb8..85fc548 100644 --- a/gen/db/user.sql.go +++ b/gen/db/user.sql.go @@ -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 diff --git a/go.mod b/go.mod index 4af199e..d20b191 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index 74feaba..fe6e671 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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") diff --git a/internal/domain/auth.go b/internal/domain/auth.go index bbead22..e791cd9 100644 --- a/internal/domain/auth.go +++ b/internal/domain/auth.go @@ -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 diff --git a/internal/ports/user.go b/internal/ports/user.go index ab8e72d..4927146 100644 --- a/internal/ports/user.go +++ b/internal/ports/user.go @@ -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, diff --git a/internal/repository/lms_access.go b/internal/repository/lms_access.go index b6680be..3bde128 100644 --- a/internal/repository/lms_access.go +++ b/internal/repository/lms_access.go @@ -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) diff --git a/internal/repository/user.go b/internal/repository/user.go index 69f9e99..420db55 100644 --- a/internal/repository/user.go +++ b/internal/repository/user.go @@ -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, diff --git a/internal/services/authentication/apple.go b/internal/services/authentication/apple.go new file mode 100644 index 0000000..1927601 --- /dev/null +++ b/internal/services/authentication/apple.go @@ -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 +} diff --git a/internal/services/lmsprogress/service.go b/internal/services/lmsprogress/service.go index d2d20cf..c04c84d 100644 --- a/internal/services/lmsprogress/service.go +++ b/internal/services/lmsprogress/service.go @@ -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 } diff --git a/internal/web_server/handlers/auth_handler.go b/internal/web_server/handlers/auth_handler.go index bdcb329..7559817 100644 --- a/internal/web_server/handlers/auth_handler.go +++ b/internal/web_server/handlers/auth_handler.go @@ -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 diff --git a/internal/web_server/routes.go b/internal/web_server/routes.go index 9fe4170..f690fe8 100644 --- a/internal/web_server/routes.go +++ b/internal/web_server/routes.go @@ -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)