- Updated docker-compose.tools.yml to mount source code at /src and set working directory for backend tools, ensuring proper Go module caching. - Modified Dockerfile.tools to install the latest golangci-lint version compatible with Go 1.24 and adjusted working directory for build-time operations. - Enhanced Makefile to build backend tools before running tests and linting, ensuring dependencies are up-to-date and improving overall workflow efficiency. - Refactored test and handler files to include error handling for database operations, enhancing reliability and debugging capabilities.
559 lines
15 KiB
Go
559 lines
15 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.
|
|
// Uses direct args (single dependency) rather than a deps struct.
|
|
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
|
|
if err := 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
|
|
}); err != nil {
|
|
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
|
|
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
|
raw := s.st.GetUser(tx, username)
|
|
if raw == nil {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(raw, &u); err != nil {
|
|
return err
|
|
}
|
|
if u.Pass == nil {
|
|
u = nil
|
|
return nil
|
|
}
|
|
if bcrypt.CompareHashAndPassword(u.Pass, []byte(pass)) != nil {
|
|
u = nil
|
|
return nil
|
|
}
|
|
return nil
|
|
}); err != 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
|
|
if err := s.st.View(ctx, func(tx *bbolt.Tx) error {
|
|
raw := s.st.GetUser(tx, username)
|
|
if raw != nil {
|
|
if err := json.Unmarshal(raw, &u); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
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
|
|
if err := 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
|
|
}); err != nil {
|
|
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
|
|
if err := json.Unmarshal(uRaw, &u); err != nil {
|
|
return err
|
|
}
|
|
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 {
|
|
if err := json.Unmarshal(uRaw, &u); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
u.Tokens = append(u.Tokens, token)
|
|
tokens = u.Tokens
|
|
buf, _ := json.Marshal(u)
|
|
if err := s.st.PutUser(tx, username, buf); err != nil {
|
|
return err
|
|
}
|
|
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 {
|
|
if err := json.Unmarshal(uRaw, &u); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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
|
|
if err := json.Unmarshal(uRaw, &u); err != nil {
|
|
return err
|
|
}
|
|
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
|
|
}
|
|
if err := json.Unmarshal(raw, &st); err != nil {
|
|
return err
|
|
}
|
|
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 err := json.Unmarshal(v, &user); err != nil {
|
|
return err
|
|
}
|
|
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{}
|
|
if err := json.Unmarshal(raw, &user); err != nil {
|
|
return err
|
|
}
|
|
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)
|
|
if err := s.st.PutUser(tx, username, raw); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
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
|
|
}
|