Files
OpenFlare/backend/plugins/domain/auth/service/oauth_service.go
T

441 lines
13 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
// Package service implements domain business services and orchestration for the auth plugin.
package service
import (
"Wavelet/core/contracts"
"Wavelet/pkg/idgen"
"Wavelet/plugins/domain/auth/consts"
"Wavelet/plugins/domain/auth/dao"
"Wavelet/plugins/domain/auth/model/dto"
"Wavelet/plugins/domain/auth/model/entity"
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
"gorm.io/gorm"
)
// OAuthService orchestrates OAuth/OIDC operations.
type OAuthService struct {
dao *dao.DAO
providerCache *OIDCProviderCache
sessionSvc *SessionService
}
// NewOAuthService creates a new OAuthService.
func NewOAuthService(d *dao.DAO, cache *OIDCProviderCache, sessSvc *SessionService) *OAuthService {
return &OAuthService{
dao: d,
providerCache: cache,
sessionSvc: sessSvc,
}
}
// IsOIDCLoginEnabled checks if OIDC login is globally enabled.
func (s *OAuthService) IsOIDCLoginEnabled(ctx context.Context) bool {
val, err := s.dao.GetSystemConfigValue(ctx, "oidc_login_enabled")
if err != nil || val == "" {
return true
}
b, err := strconv.ParseBool(val)
if err != nil {
return true
}
return b
}
// ResolveAuthSource retrieves the specified or default active auth source.
func (s *OAuthService) ResolveAuthSource(ctx context.Context, sourceName string) (*entity.AuthSource, error) {
name := strings.TrimSpace(strings.ToLower(sourceName))
if name == "" {
sources, err := s.dao.ListActiveAuthSources(ctx)
if err != nil {
return nil, err
}
if len(sources) == 0 {
return nil, errors.New(consts.ErrNoActiveAuthSource)
}
src, err := s.dao.GetAuthSourceByName(ctx, sources[0].Name)
if err != nil {
return nil, err
}
return src, nil
}
src, err := s.dao.GetAuthSourceByName(ctx, name)
if err != nil {
return nil, err
}
return src, nil
}
// ActiveLoginSources returns all active login sources formatted for display.
func (s *OAuthService) ActiveLoginSources(ctx context.Context) ([]dto.AuthSourceView, error) {
if !s.IsOIDCLoginEnabled(ctx) {
return nil, nil
}
dbSources, err := s.dao.ListActiveAuthSources(ctx)
if err != nil {
return nil, err
}
sources := make([]dto.AuthSourceView, 0, len(dbSources))
for _, source := range dbSources {
sources = append(sources, dto.AuthSourceView{
ID: source.ID,
Name: source.Name,
Type: source.Type,
DisplayName: source.DisplayName,
IsActive: source.IsActive,
IconURL: source.IconURL,
ClientSecretConfigured: source.ClientSecretConfigured,
})
}
return sources, nil
}
// GetFrontendLoginRedirectURL constructs the OAuth frontend redirect URL.
func (s *OAuthService) GetFrontendLoginRedirectURL(ctx context.Context) (string, error) {
val, err := s.dao.GetSystemConfigValue(ctx, "server_address")
if err != nil || strings.TrimSpace(val) == "" {
return "", errors.New(consts.ErrServerAddressMissing)
}
return strings.TrimRight(val, "/") + "/login", nil
}
// ReserveOAuthStateSlot ensures that a session does not abuse OAuth state generation.
func (s *OAuthService) ReserveOAuthStateSlot(ctx context.Context, sessionHash string) error {
if sessionHash == "" {
return nil
}
if limiter := s.dao.Limiter(); limiter != nil {
key := fmt.Sprintf(consts.OAuthStateLimitKeyFormat, sessionHash)
res, err := limiter.Allow(ctx, key, contracts.Rate{
Limit: consts.OAuthStateLimitMax,
Period: consts.OAuthStateCacheKeyExpiration,
})
if err != nil {
return err
}
if !res.Allowed {
return errors.New(consts.ErrOAuthStateRateLimited)
}
return nil
}
cache := s.dao.Cache()
if cache == nil {
return nil
}
key := fmt.Sprintf(consts.OAuthStateLimitKeyFormat, sessionHash)
var count int
_ = cache.Get(ctx, key, &count)
count++
_ = cache.Set(ctx, key, count, consts.OAuthStateCacheKeyExpiration)
if count > consts.OAuthStateLimitMax {
return errors.New(consts.ErrOAuthStateRateLimited)
}
return nil
}
// BuildOAuthConfig builds oauth2.Config and oidc.IDTokenVerifier.
func (s *OAuthService) BuildOAuthConfig(ctx context.Context, source *entity.AuthSource, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) {
if source == nil {
return nil, nil, errors.New(consts.ErrAuthSourceRequired)
}
if source.OpenIDDiscoveryURL == "" {
return nil, nil, errors.New(consts.ErrDiscoveryURLRequired)
}
issuer := strings.TrimSuffix(strings.TrimSpace(source.OpenIDDiscoveryURL), "/")
issuer = strings.TrimSuffix(issuer, "/.well-known/openid-configuration")
issuer = strings.TrimSuffix(issuer, "/.well-known/oauth-authorization-server")
provider, err := s.providerCache.Get(ctx, issuer)
if err != nil {
return nil, nil, err
}
verifier := provider.Verifier(&oidc.Config{ClientID: source.ClientID})
scopes := strings.Fields(source.Scopes)
if len(scopes) == 0 {
scopes = []string{oidc.ScopeOpenID, "profile", "email"}
}
if !containsScope(scopes, oidc.ScopeOpenID) {
scopes = append([]string{oidc.ScopeOpenID}, scopes...)
}
return &oauth2.Config{
ClientID: source.ClientID,
ClientSecret: source.ClientSecret,
RedirectURL: redirectURL,
Scopes: scopes,
Endpoint: provider.Endpoint(),
}, verifier, nil
}
func containsScope(scopes []string, scope string) bool {
for _, item := range scopes {
if item == scope {
return true
}
}
return false
}
// BuildAuthorizeURL generates the redirect authorize URL for the source and state.
func (s *OAuthService) BuildAuthorizeURL(ctx context.Context, source *entity.AuthSource, state string) (string, error) {
redirectURL, err := s.GetFrontendLoginRedirectURL(ctx)
if err != nil {
return "", err
}
authConfig, verifier, err := s.BuildOAuthConfig(ctx, source, redirectURL)
if err != nil {
return "", err
}
if verifier != nil {
return authConfig.AuthCodeURL(state, oidc.Nonce(state)), nil
}
return authConfig.AuthCodeURL(state), nil
}
// BuildOAuthUserInfo exchanges the auth code and retrieves user identity claims.
func (s *OAuthService) BuildOAuthUserInfo(ctx context.Context, source *entity.AuthSource, code, nonce, redirectURL string) (*contracts.OAuthUserInfoDTO, error) {
authConfig, verifier, err := s.BuildOAuthConfig(ctx, source, redirectURL)
if err != nil {
return nil, err
}
token, err := authConfig.Exchange(ctx, code)
if err != nil {
return nil, err
}
userInfo := &contracts.OAuthUserInfoDTO{Active: true}
if verifier != nil {
if verifyErr := s.verifyIDToken(ctx, verifier, token, nonce, userInfo); verifyErr != nil {
return nil, verifyErr
}
}
if userInfo.Username == "" && userInfo.PreferredUsername != "" {
userInfo.Username = userInfo.PreferredUsername
}
if userInfo.Username == "" && userInfo.Email != "" {
userInfo.Username = strings.Split(userInfo.Email, "@")[0]
}
if userInfo.Username == "" && userInfo.Sub != "" {
userInfo.Username = userInfo.Sub
}
if userInfo.Name == "" {
userInfo.Name = userInfo.Username
}
return userInfo, nil
}
func (s *OAuthService) verifyIDToken(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token, nonce string, userInfo *contracts.OAuthUserInfoDTO) error {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return nil
}
idToken, verifyErr := verifier.Verify(ctx, rawIDToken)
if verifyErr != nil {
return fmt.Errorf(consts.ErrIDTokenVerifyFailedFormat, consts.ErrIDTokenVerifyFailed, verifyErr)
}
if nonce != "" && idToken.Nonce != nonce {
return errors.New(consts.ErrNonceMismatch)
}
if claimsErr := idToken.Claims(userInfo); claimsErr != nil {
return claimsErr
}
return nil
}
// NormalizeOAuthUserInfo sanitizes user claims.
func (s *OAuthService) NormalizeOAuthUserInfo(userInfo *contracts.OAuthUserInfoDTO) error {
userInfo.Username = strings.TrimSpace(userInfo.Username)
userInfo.PreferredUsername = strings.TrimSpace(userInfo.PreferredUsername)
userInfo.Email = strings.TrimSpace(userInfo.Email)
userInfo.Name = strings.TrimSpace(userInfo.Name)
userInfo.AvatarURL = strings.TrimSpace(userInfo.AvatarURL)
if userInfo.Username == "" && userInfo.PreferredUsername != "" {
userInfo.Username = userInfo.PreferredUsername
}
if userInfo.Username == "" && userInfo.Email != "" {
userInfo.Username = strings.Split(userInfo.Email, "@")[0]
}
if userInfo.Username == "" && userInfo.Sub != "" {
userInfo.Username = userInfo.Sub
}
if userInfo.Username == "" {
return errors.New(consts.ErrUsernameFromSourceFailed)
}
if userInfo.Name == "" {
userInfo.Name = userInfo.Username
}
if !userInfo.Active {
userInfo.Active = true
}
return nil
}
// UniqueUsername generates a unique username given a base candidate.
func (s *OAuthService) UniqueUsername(ctx context.Context, base string) (string, error) {
base = strings.TrimSpace(base)
if base == "" {
base = "user"
}
existingUsernames, err := s.dao.ListSimilarUsernames(ctx, base)
if err != nil {
return "", err
}
exists := make(map[string]bool, len(existingUsernames))
for _, u := range existingUsernames {
exists[strings.ToLower(u)] = true
}
if !exists[strings.ToLower(base)] {
return base, nil
}
for i := 1; i <= 1000; i++ {
candidate := fmt.Sprintf("%s-%d", base, i)
if !exists[strings.ToLower(candidate)] {
return candidate, nil
}
}
return "", errors.New(consts.ErrUsernameGenerateFailed)
}
// BindExternalAccount binds an external identity to an existing user.
func (s *OAuthService) BindExternalAccount(ctx context.Context, sourceID, userID uint64, userInfo *contracts.OAuthUserInfoDTO) error {
user, err := s.dao.GetUserByID(ctx, userID)
if err != nil {
return err
}
if err := s.dao.BindExternalAccount(ctx, &entity.ExternalAccount{
AuthSourceID: sourceID,
UserID: user.ID,
ExternalID: userInfo.Sub,
ExternalUsername: userInfo.Username,
Email: userInfo.Email,
}); err != nil {
return err
}
user.LastLoginAt = time.Now()
_ = s.dao.TouchUserLastLogin(ctx, user.ID, user.LastLoginAt)
return nil
}
// AuthenticateOrRegisterUser finds existing binding or creates a new user.
func (s *OAuthService) AuthenticateOrRegisterUser(ctx context.Context, source *entity.AuthSource, userInfo *contracts.OAuthUserInfoDTO) (*contracts.UserDTO, bool, error) {
account, err := s.dao.FindExternalAccount(ctx, source.ID, userInfo.Sub)
if err == nil {
user, loadErr := s.dao.GetUserByID(ctx, account.UserID)
if loadErr != nil {
return nil, false, loadErr
}
user.LastLoginAt = time.Now()
_ = s.dao.TouchUserLastLogin(ctx, user.ID, user.LastLoginAt)
return user, true, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, err
}
// Not found -> check registration
registrationEnabled := true
val, cfgErr := s.dao.GetSystemConfigValue(ctx, "registration_enabled")
if cfgErr == nil && val != "" {
if b, err := strconv.ParseBool(val); err == nil {
registrationEnabled = b
}
}
if !registrationEnabled {
return nil, false, nil // registration disabled -> need bind
}
username, uniqueErr := s.UniqueUsername(ctx, userInfo.Username)
if uniqueErr != nil {
return nil, false, uniqueErr
}
userInfo.Username = username
now := time.Now()
user := contracts.UserDTO{
ID: idgen.NextUint64ID(),
Username: userInfo.Username,
Nickname: userInfo.Name,
Email: userInfo.Email,
AvatarURL: userInfo.AvatarURL,
IsActive: userInfo.Active,
LastLoginAt: now,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.dao.InsertUser(ctx, &user); err != nil {
return nil, false, err
}
if err := s.dao.BindExternalAccount(ctx, &entity.ExternalAccount{
AuthSourceID: source.ID,
UserID: user.ID,
ExternalID: userInfo.Sub,
ExternalUsername: userInfo.Username,
Email: userInfo.Email,
}); err != nil {
return nil, false, err
}
return &user, true, nil
}
// ListExternalAccounts returns sanitized external account bindings.
func (s *OAuthService) ListExternalAccounts(ctx context.Context, userID uint64) ([]dto.ExternalAccountView, error) {
accounts, err := s.dao.ListExternalAccountsByUserID(ctx, userID)
if err != nil {
return nil, err
}
views := make([]dto.ExternalAccountView, len(accounts))
for i, acc := range accounts {
source, _ := s.dao.GetAuthSourceByID(ctx, acc.AuthSourceID)
sourceName, sourceType, sourceLabel := "", "", ""
if source != nil {
sourceName = source.Name
sourceType = source.Type
sourceLabel = source.DisplayName
}
views[i] = dto.ExternalAccountView{
ID: acc.ID,
AuthSourceID: acc.AuthSourceID,
AuthSourceName: sourceName,
AuthSourceType: sourceType,
AuthSourceLabel: sourceLabel,
ExternalUsername: acc.ExternalUsername,
Email: acc.Email,
CreatedAt: acc.CreatedAt.Format(time.RFC3339),
}
}
return views, nil
}
// DeleteExternalAccount unbinds an external account.
func (s *OAuthService) DeleteExternalAccount(ctx context.Context, id, userID uint64) error {
return s.dao.UnbindExternalAccount(ctx, id, userID)
}