Files
hnh-map/internal/app/services/auth.go
Nikolay Tatarinov 6a6977ddff Enhance user profile management and Gravatar integration
- Added email field to user profile API and frontend components for better user identification.
- Implemented PATCH /map/api/me endpoint to update user email, enhancing user experience.
- Introduced useGravatarUrl composable for generating Gravatar URLs based on user email.
- Updated profile and layout components to display user avatars using Gravatar, improving visual consistency.
- Enhanced development documentation to guide testing of navbar and profile features.
2026-03-01 16:48:56 +03:00

530 lines
14 KiB
Go

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)
})
}
// SetUserEmail sets email for user (for Gravatar and display).
func (s *AuthService) SetUserEmail(ctx context.Context, username, email string) error {
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
uRaw := s.st.GetUser(tx, username)
if uRaw == nil {
return nil // user not found, no-op
}
var u app.User
if err := json.Unmarshal(uRaw, &u); err != nil {
return err
}
u.Email = email
raw, err := json.Marshal(u)
if err != nil {
return err
}
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
}
if email != "" {
user.Email = email
}
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},
Email: email,
}
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
}