feat: fb and google oauth
This commit is contained in:
@@ -39,6 +39,7 @@ var (
|
||||
type AuthService struct {
|
||||
users *repository.UserRepo
|
||||
tokens *repository.AuthTokenRepo
|
||||
oauth *repository.OAuthAccountRepo // optional — nil disables OAuth flows
|
||||
jwt *auth.Issuer
|
||||
mail mailer.Mailer
|
||||
logger *zap.Logger
|
||||
@@ -49,10 +50,12 @@ type AuthService struct {
|
||||
EmailVerifyTTL time.Duration
|
||||
}
|
||||
|
||||
// NewAuthService wires the dependencies.
|
||||
// NewAuthService wires the dependencies. `oauthRepo` may be nil — pass nil if
|
||||
// the OAuth flows aren't wired (handler then returns 503).
|
||||
func NewAuthService(
|
||||
users *repository.UserRepo,
|
||||
tokens *repository.AuthTokenRepo,
|
||||
oauthRepo *repository.OAuthAccountRepo,
|
||||
jwt *auth.Issuer,
|
||||
mail mailer.Mailer,
|
||||
logger *zap.Logger,
|
||||
@@ -61,6 +64,7 @@ func NewAuthService(
|
||||
return &AuthService{
|
||||
users: users,
|
||||
tokens: tokens,
|
||||
oauth: oauthRepo,
|
||||
jwt: jwt,
|
||||
mail: mail,
|
||||
logger: logger,
|
||||
@@ -136,12 +140,135 @@ func (s *AuthService) LogIn(ctx context.Context, email, password string) (*AuthR
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)); err != nil {
|
||||
// OAuth-only accounts have no password hash. Treat them as a credential
|
||||
// mismatch so we don't reveal that an OAuth user exists for this email.
|
||||
if u.PasswordHash == nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(*u.PasswordHash), []byte(password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
return s.issueSession(u)
|
||||
}
|
||||
|
||||
// OAuthProfile is the provider-agnostic profile shape consumed by
|
||||
// SignInWithOAuth. Mirrors pkg/oauth.Profile but lives here so the service
|
||||
// layer doesn't import the OAuth package (which would create a layering
|
||||
// cycle once handlers compose both).
|
||||
type OAuthProfile struct {
|
||||
Provider string
|
||||
ProviderUserID string
|
||||
Email string
|
||||
EmailVerified bool
|
||||
Name string
|
||||
AvatarURL string
|
||||
}
|
||||
|
||||
// ErrOAuthEmailMissing is returned by SignInWithOAuth when the provider did
|
||||
// not give us an email and the account has no linkage we could match on.
|
||||
// Without an email we can't safely create a new user (since later sign-ins
|
||||
// via email/password would have no way to converge with this account).
|
||||
var ErrOAuthEmailMissing = errors.New("oauth provider did not return an email")
|
||||
|
||||
// ErrOAuthUnavailable means the OAuth repo wasn't wired (no Postgres at
|
||||
// startup). Handler maps to 503.
|
||||
var ErrOAuthUnavailable = errors.New("oauth service unavailable")
|
||||
|
||||
// SignInWithOAuth turns a provider profile into a session. Three-step lookup:
|
||||
//
|
||||
// 1. Existing linkage on (provider, provider_user_id) → log that user in.
|
||||
// 2. Existing user with same email → link this provider to them, log in.
|
||||
// 3. Otherwise create a new user (no password, email_verified iff provider
|
||||
// said so) and link.
|
||||
//
|
||||
// In every case the resulting user gets a fresh JWT just like LogIn.
|
||||
func (s *AuthService) SignInWithOAuth(ctx context.Context, prof OAuthProfile) (*AuthResult, error) {
|
||||
if s.oauth == nil {
|
||||
return nil, ErrOAuthUnavailable
|
||||
}
|
||||
|
||||
// 1. Known linkage.
|
||||
if a, err := s.oauth.FindByProvider(ctx, prof.Provider, prof.ProviderUserID); err == nil {
|
||||
u, err := s.users.FindByID(ctx, a.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Refresh denormalised fields on the linkage row (display name /
|
||||
// avatar may have changed at the provider).
|
||||
_, _ = s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
|
||||
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||
return s.issueSession(u)
|
||||
} else if !errors.Is(err, repository.ErrOAuthAccountNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// No linkage yet — we need at least an email to safely converge with an
|
||||
// existing account or create a new one.
|
||||
if prof.Email == "" {
|
||||
return nil, ErrOAuthEmailMissing
|
||||
}
|
||||
|
||||
// 2. Existing user with the same email → link.
|
||||
if u, err := s.users.FindByEmail(ctx, prof.Email); err == nil {
|
||||
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
|
||||
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.issueSession(u)
|
||||
} else if !errors.Is(err, repository.ErrUserNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Brand new user. Reuse the handle-collision retry loop.
|
||||
displayName := prof.Name
|
||||
if displayName == "" {
|
||||
displayName = strings.SplitN(prof.Email, "@", 2)[0]
|
||||
}
|
||||
base := slugForHandle(prof.Email)
|
||||
var u *model.User
|
||||
var err error
|
||||
for attempt := 0; attempt < 8; attempt++ {
|
||||
handle := base
|
||||
if attempt > 0 {
|
||||
handle = fmt.Sprintf("%s-%s", base, randomHandleSuffix())
|
||||
}
|
||||
u, err = s.users.CreateOAuth(ctx, prof.Email, displayName, handle, optStr(prof.AvatarURL), prof.EmailVerified)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if errors.Is(err, repository.ErrEmailTaken) {
|
||||
// Race: someone else just claimed this email. Fall back to step 2.
|
||||
if u2, err2 := s.users.FindByEmail(ctx, prof.Email); err2 == nil {
|
||||
_, _ = s.oauth.Link(ctx, u2.ID, prof.Provider, prof.ProviderUserID,
|
||||
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL))
|
||||
return s.issueSession(u2)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !errors.Is(err, repository.ErrHandleTaken) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create oauth user: %w", err)
|
||||
}
|
||||
|
||||
if _, err := s.oauth.Link(ctx, u.ID, prof.Provider, prof.ProviderUserID,
|
||||
optStr(prof.Email), optStr(prof.Name), optStr(prof.AvatarURL)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.issueSession(u)
|
||||
}
|
||||
|
||||
// optStr returns nil for empty input, &s otherwise — used to populate
|
||||
// nullable text columns from possibly-empty profile fields.
|
||||
func optStr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// Me looks up a user by id and re-verifies the JWT's token_version against
|
||||
// the stored value. Mismatch means the user has logged-out-everywhere or
|
||||
// changed password since this JWT was issued → reject.
|
||||
|
||||
Reference in New Issue
Block a user