package services import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "log/slog" "net/http" "net/url" "os" "strings" "time" "github.com/andyleap/hnh-map/internal/app" "github.com/andyleap/hnh-map/internal/app/apperr" "github.com/andyleap/hnh-map/internal/app/store" "go.etcd.io/bbolt" "golang.org/x/crypto/bcrypt" "golang.org/x/oauth2" "golang.org/x/oauth2/google" ) const oauthStateTTL = 10 * time.Minute type oauthState struct { Provider string `json:"provider"` RedirectURI string `json:"redirect_uri,omitempty"` CreatedAt int64 `json:"created_at"` } type googleUserInfo struct { Sub string `json:"sub"` Email string `json:"email"` Name string `json:"name"` } // AuthService handles authentication, sessions, and OAuth business logic. type AuthService struct { st *store.Store } // NewAuthService creates an AuthService with the given store. func NewAuthService(st *store.Store) *AuthService { return &AuthService{st: st} } // GetSession returns the session from the request cookie, or nil. func (s *AuthService) GetSession(ctx context.Context, req *http.Request) *app.Session { c, err := req.Cookie("session") if err != nil { return nil } var sess *app.Session s.st.View(ctx, func(tx *bbolt.Tx) error { raw := s.st.GetSession(tx, c.Value) if raw == nil { return nil } if err := json.Unmarshal(raw, &sess); err != nil { return err } if sess.TempAdmin { sess.Auths = app.Auths{app.AUTH_ADMIN} return nil } uRaw := s.st.GetUser(tx, sess.Username) if uRaw == nil { sess = nil return nil } var u app.User if err := json.Unmarshal(uRaw, &u); err != nil { sess = nil return err } sess.Auths = u.Auths return nil }) return sess } // DeleteSession removes a session. func (s *AuthService) DeleteSession(ctx context.Context, sess *app.Session) { if err := s.st.Update(ctx, func(tx *bbolt.Tx) error { return s.st.DeleteSession(tx, sess.ID) }); err != nil { slog.Error("failed to delete session", "session_id", sess.ID, "error", err) } } // SaveSession stores a session. func (s *AuthService) SaveSession(ctx context.Context, sess *app.Session) error { return s.st.Update(ctx, func(tx *bbolt.Tx) error { buf, err := json.Marshal(sess) if err != nil { return err } return s.st.PutSession(tx, sess.ID, buf) }) } // CreateSession creates a session for username, returns session ID or empty string. func (s *AuthService) CreateSession(ctx context.Context, username string, tempAdmin bool) string { session := make([]byte, 32) if _, err := rand.Read(session); err != nil { return "" } sid := hex.EncodeToString(session) sess := &app.Session{ ID: sid, Username: username, TempAdmin: tempAdmin, } if s.SaveSession(ctx, sess) != nil { return "" } return sid } // GetUser returns user if username/password match. func (s *AuthService) GetUser(ctx context.Context, username, pass string) *app.User { var u *app.User s.st.View(ctx, func(tx *bbolt.Tx) error { raw := s.st.GetUser(tx, username) if raw == nil { return nil } json.Unmarshal(raw, &u) if u.Pass == nil { u = nil return nil } if bcrypt.CompareHashAndPassword(u.Pass, []byte(pass)) != nil { u = nil return nil } return nil }) return u } // GetUserByUsername returns user without password check (for OAuth-only check). func (s *AuthService) GetUserByUsername(ctx context.Context, username string) *app.User { var u *app.User s.st.View(ctx, func(tx *bbolt.Tx) error { raw := s.st.GetUser(tx, username) if raw != nil { json.Unmarshal(raw, &u) } return nil }) return u } // SetupRequired returns true if no users exist (first run). func (s *AuthService) SetupRequired(ctx context.Context) bool { var required bool s.st.View(ctx, func(tx *bbolt.Tx) error { if s.st.UserCount(tx) == 0 { required = true } return nil }) return required } // BootstrapAdmin creates the first admin user if bootstrap env is set and no users exist. func (s *AuthService) BootstrapAdmin(ctx context.Context, username, pass, bootstrapEnv string) *app.User { if username != "admin" || pass == "" || bootstrapEnv == "" || pass != bootstrapEnv { return nil } var created bool var u *app.User s.st.Update(ctx, func(tx *bbolt.Tx) error { if s.st.GetUser(tx, "admin") != nil { return nil } hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) if err != nil { return err } user := app.User{ Pass: hash, Auths: app.Auths{app.AUTH_ADMIN, app.AUTH_MAP, app.AUTH_MARKERS, app.AUTH_UPLOAD}, } raw, _ := json.Marshal(user) if err := s.st.PutUser(tx, "admin", raw); err != nil { return err } created = true u = &user return nil }) if created { return u } return nil } // GetBootstrapPassword returns HNHMAP_BOOTSTRAP_PASSWORD from env. func GetBootstrapPassword() string { return os.Getenv("HNHMAP_BOOTSTRAP_PASSWORD") } // GetUserTokensAndPrefix returns tokens and config prefix for a user. func (s *AuthService) GetUserTokensAndPrefix(ctx context.Context, username string) (tokens []string, prefix string) { s.st.View(ctx, func(tx *bbolt.Tx) error { uRaw := s.st.GetUser(tx, username) if uRaw != nil { var u app.User json.Unmarshal(uRaw, &u) tokens = u.Tokens } if p := s.st.GetConfig(tx, "prefix"); p != nil { prefix = string(p) } return nil }) return tokens, prefix } // GenerateTokenForUser adds a new token for user and returns the full list. func (s *AuthService) GenerateTokenForUser(ctx context.Context, username string) []string { tokenRaw := make([]byte, 16) if _, err := rand.Read(tokenRaw); err != nil { return nil } token := hex.EncodeToString(tokenRaw) var tokens []string s.st.Update(ctx, func(tx *bbolt.Tx) error { uRaw := s.st.GetUser(tx, username) u := app.User{} if uRaw != nil { json.Unmarshal(uRaw, &u) } u.Tokens = append(u.Tokens, token) tokens = u.Tokens buf, _ := json.Marshal(u) s.st.PutUser(tx, username, buf) return s.st.PutToken(tx, token, username) }) return tokens } // SetUserPassword sets password for user (empty pass = no change). func (s *AuthService) SetUserPassword(ctx context.Context, username, pass string) error { if pass == "" { return nil } return s.st.Update(ctx, func(tx *bbolt.Tx) error { uRaw := s.st.GetUser(tx, username) u := app.User{} if uRaw != nil { json.Unmarshal(uRaw, &u) } hash, err := bcrypt.GenerateFromPassword([]byte(pass), bcrypt.DefaultCost) if err != nil { return err } u.Pass = hash raw, _ := json.Marshal(u) return s.st.PutUser(tx, username, raw) }) } // ValidateClientToken validates a client token and returns the username if valid with upload permission. func (s *AuthService) ValidateClientToken(ctx context.Context, token string) (string, error) { var username string err := s.st.View(ctx, func(tx *bbolt.Tx) error { userName := s.st.GetTokenUser(tx, token) if userName == nil { return apperr.ErrUnauthorized } uRaw := s.st.GetUser(tx, string(userName)) if uRaw == nil { return apperr.ErrUnauthorized } var u app.User json.Unmarshal(uRaw, &u) if !u.Auths.Has(app.AUTH_UPLOAD) { return apperr.ErrForbidden } username = string(userName) return nil }) return username, err } // --- OAuth --- // OAuthConfig returns OAuth2 config for the given provider, or nil if not configured. func OAuthConfig(provider string, baseURL string) *oauth2.Config { switch provider { case "google": clientID := os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_ID") clientSecret := os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET") if clientID == "" || clientSecret == "" { return nil } redirectURL := strings.TrimSuffix(baseURL, "/") + "/map/api/oauth/google/callback" return &oauth2.Config{ ClientID: clientID, ClientSecret: clientSecret, RedirectURL: redirectURL, Scopes: []string{"openid", "email", "profile"}, Endpoint: google.Endpoint, } default: return nil } } // BaseURL returns the configured base URL for the app. func BaseURL(req *http.Request) string { if base := os.Getenv("HNHMAP_BASE_URL"); base != "" { return strings.TrimSuffix(base, "/") } scheme := "https" if req.TLS == nil { scheme = "http" } host := req.Host if h := req.Header.Get("X-Forwarded-Host"); h != "" { host = h } if proto := req.Header.Get("X-Forwarded-Proto"); proto != "" { scheme = proto } return scheme + "://" + host } // OAuthProviders returns list of configured OAuth providers. func OAuthProviders() []string { var providers []string if os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_ID") != "" && os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET") != "" { providers = append(providers, "google") } return providers } // OAuthInitLogin creates an OAuth state and returns the redirect URL. func (s *AuthService) OAuthInitLogin(ctx context.Context, provider, redirectURI string, req *http.Request) (string, error) { baseURL := BaseURL(req) cfg := OAuthConfig(provider, baseURL) if cfg == nil { return "", apperr.ErrProviderUnconfigured } state := make([]byte, 32) if _, err := rand.Read(state); err != nil { return "", err } stateStr := hex.EncodeToString(state) st := oauthState{ Provider: provider, RedirectURI: redirectURI, CreatedAt: time.Now().Unix(), } stRaw, _ := json.Marshal(st) if err := s.st.Update(ctx, func(tx *bbolt.Tx) error { return s.st.PutOAuthState(tx, stateStr, stRaw) }); err != nil { return "", err } authURL := cfg.AuthCodeURL(stateStr, oauth2.AccessTypeOffline) return authURL, nil } // OAuthHandleCallback processes the OAuth callback, validates state, exchanges code, and creates a session. // Returns (sessionID, redirectPath, error). func (s *AuthService) OAuthHandleCallback(ctx context.Context, provider, code, state string, req *http.Request) (string, string, error) { baseURL := BaseURL(req) cfg := OAuthConfig(provider, baseURL) if cfg == nil { return "", "", apperr.ErrProviderUnconfigured } var st oauthState err := s.st.Update(ctx, func(tx *bbolt.Tx) error { raw := s.st.GetOAuthState(tx, state) if raw == nil { return apperr.ErrBadRequest } json.Unmarshal(raw, &st) return s.st.DeleteOAuthState(tx, state) }) if err != nil || st.Provider == "" { return "", "", apperr.ErrBadRequest } if time.Since(time.Unix(st.CreatedAt, 0)) > oauthStateTTL { return "", "", apperr.ErrStateExpired } if st.Provider != provider { return "", "", apperr.ErrStateMismatch } tok, err := cfg.Exchange(ctx, code) if err != nil { slog.Error("OAuth exchange failed", "provider", provider, "error", err) return "", "", apperr.ErrExchangeFailed } var sub, email string switch provider { case "google": sub, email, err = fetchGoogleUserInfo(ctx, tok.AccessToken) if err != nil { slog.Error("failed to get Google user info", "error", err) return "", "", apperr.ErrUserInfoFailed } default: return "", "", apperr.ErrBadRequest } username := s.findOrCreateOAuthUser(ctx, provider, sub, email) if username == "" { return "", "", apperr.ErrInternal } sessionID := s.CreateSession(ctx, username, false) if sessionID == "" { return "", "", apperr.ErrInternal } redirectTo := "/profile" if st.RedirectURI != "" { if u, err := url.Parse(st.RedirectURI); err == nil && u.Path != "" && !strings.HasPrefix(u.Path, "//") { redirectTo = u.Path if u.RawQuery != "" { redirectTo += "?" + u.RawQuery } } } return sessionID, redirectTo, nil } func fetchGoogleUserInfo(ctx context.Context, accessToken string) (sub, email string, err error) { req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v3/userinfo", nil) if err != nil { return "", "", err } req.Header.Set("Authorization", "Bearer "+accessToken) resp, err := http.DefaultClient.Do(req) if err != nil { return "", "", err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return "", "", apperr.ErrUserInfoFailed } var info googleUserInfo if err := json.NewDecoder(resp.Body).Decode(&info); err != nil { return "", "", err } return info.Sub, info.Email, nil } func (s *AuthService) findOrCreateOAuthUser(ctx context.Context, provider, sub, email string) string { var username string err := s.st.Update(ctx, func(tx *bbolt.Tx) error { _ = s.st.ForEachUser(tx, func(k, v []byte) error { user := app.User{} if json.Unmarshal(v, &user) != nil { return nil } if user.OAuthLinks != nil && user.OAuthLinks[provider] == sub { username = string(k) } return nil }) if username != "" { raw := s.st.GetUser(tx, username) if raw != nil { user := app.User{} json.Unmarshal(raw, &user) if user.OAuthLinks == nil { user.OAuthLinks = map[string]string{provider: sub} } else { user.OAuthLinks[provider] = sub } raw, _ = json.Marshal(user) s.st.PutUser(tx, username, raw) } return nil } username = email if username == "" { username = provider + "_" + sub } if s.st.GetUser(tx, username) != nil { username = provider + "_" + sub } newUser := &app.User{ Pass: nil, Auths: app.Auths{app.AUTH_MAP, app.AUTH_MARKERS, app.AUTH_UPLOAD}, OAuthLinks: map[string]string{provider: sub}, } raw, _ := json.Marshal(newUser) return s.st.PutUser(tx, username, raw) }) if err != nil { slog.Error("failed to find or create OAuth user", "provider", provider, "error", err) return "" } return username }