Add configuration files and update project documentation
- Introduced .editorconfig for consistent coding styles across the project. - Added .golangci.yml for Go linting configuration. - Updated AGENTS.md to clarify project structure and components. - Enhanced CONTRIBUTING.md with Makefile usage for common tasks. - Updated Dockerfiles to use Go 1.24 and improved build instructions. - Refined README.md and deployment documentation for clarity. - Added testing documentation in testing.md for backend and frontend tests. - Introduced Makefile for streamlined development commands and tasks.
This commit is contained in:
@@ -1,36 +1,58 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// AuthService handles authentication and session business logic.
|
||||
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.
|
||||
// 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(req *http.Request) *app.Session {
|
||||
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(func(tx *bbolt.Tx) error {
|
||||
s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetSession(tx, c.Value)
|
||||
if raw == nil {
|
||||
return nil
|
||||
@@ -59,15 +81,17 @@ func (s *AuthService) GetSession(req *http.Request) *app.Session {
|
||||
}
|
||||
|
||||
// DeleteSession removes a session.
|
||||
func (s *AuthService) DeleteSession(sess *app.Session) {
|
||||
s.st.Update(func(tx *bbolt.Tx) error {
|
||||
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(sess *app.Session) error {
|
||||
return s.st.Update(func(tx *bbolt.Tx) error {
|
||||
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
|
||||
@@ -77,7 +101,7 @@ func (s *AuthService) SaveSession(sess *app.Session) error {
|
||||
}
|
||||
|
||||
// CreateSession creates a session for username, returns session ID or empty string.
|
||||
func (s *AuthService) CreateSession(username string, tempAdmin bool) 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 ""
|
||||
@@ -88,16 +112,16 @@ func (s *AuthService) CreateSession(username string, tempAdmin bool) string {
|
||||
Username: username,
|
||||
TempAdmin: tempAdmin,
|
||||
}
|
||||
if s.SaveSession(sess) != nil {
|
||||
if s.SaveSession(ctx, sess) != nil {
|
||||
return ""
|
||||
}
|
||||
return sid
|
||||
}
|
||||
|
||||
// GetUser returns user if username/password match.
|
||||
func (s *AuthService) GetUser(username, pass string) *app.User {
|
||||
func (s *AuthService) GetUser(ctx context.Context, username, pass string) *app.User {
|
||||
var u *app.User
|
||||
s.st.View(func(tx *bbolt.Tx) error {
|
||||
s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetUser(tx, username)
|
||||
if raw == nil {
|
||||
return nil
|
||||
@@ -117,9 +141,9 @@ func (s *AuthService) GetUser(username, pass string) *app.User {
|
||||
}
|
||||
|
||||
// GetUserByUsername returns user without password check (for OAuth-only check).
|
||||
func (s *AuthService) GetUserByUsername(username string) *app.User {
|
||||
func (s *AuthService) GetUserByUsername(ctx context.Context, username string) *app.User {
|
||||
var u *app.User
|
||||
s.st.View(func(tx *bbolt.Tx) error {
|
||||
s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
raw := s.st.GetUser(tx, username)
|
||||
if raw != nil {
|
||||
json.Unmarshal(raw, &u)
|
||||
@@ -130,9 +154,9 @@ func (s *AuthService) GetUserByUsername(username string) *app.User {
|
||||
}
|
||||
|
||||
// SetupRequired returns true if no users exist (first run).
|
||||
func (s *AuthService) SetupRequired() bool {
|
||||
func (s *AuthService) SetupRequired(ctx context.Context) bool {
|
||||
var required bool
|
||||
s.st.View(func(tx *bbolt.Tx) error {
|
||||
s.st.View(ctx, func(tx *bbolt.Tx) error {
|
||||
if s.st.UserCount(tx) == 0 {
|
||||
required = true
|
||||
}
|
||||
@@ -142,14 +166,13 @@ func (s *AuthService) SetupRequired() bool {
|
||||
}
|
||||
|
||||
// BootstrapAdmin creates the first admin user if bootstrap env is set and no users exist.
|
||||
// Returns the user if created, nil otherwise.
|
||||
func (s *AuthService) BootstrapAdmin(username, pass, bootstrapEnv string) *app.User {
|
||||
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(func(tx *bbolt.Tx) error {
|
||||
s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
if s.st.GetUser(tx, "admin") != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -181,8 +204,8 @@ func GetBootstrapPassword() string {
|
||||
}
|
||||
|
||||
// GetUserTokensAndPrefix returns tokens and config prefix for a user.
|
||||
func (s *AuthService) GetUserTokensAndPrefix(username string) (tokens []string, prefix string) {
|
||||
s.st.View(func(tx *bbolt.Tx) error {
|
||||
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
|
||||
@@ -198,14 +221,14 @@ func (s *AuthService) GetUserTokensAndPrefix(username string) (tokens []string,
|
||||
}
|
||||
|
||||
// GenerateTokenForUser adds a new token for user and returns the full list.
|
||||
func (s *AuthService) GenerateTokenForUser(username string) []string {
|
||||
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(func(tx *bbolt.Tx) error {
|
||||
s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
uRaw := s.st.GetUser(tx, username)
|
||||
u := app.User{}
|
||||
if uRaw != nil {
|
||||
@@ -221,11 +244,11 @@ func (s *AuthService) GenerateTokenForUser(username string) []string {
|
||||
}
|
||||
|
||||
// SetUserPassword sets password for user (empty pass = no change).
|
||||
func (s *AuthService) SetUserPassword(username, pass string) error {
|
||||
func (s *AuthService) SetUserPassword(ctx context.Context, username, pass string) error {
|
||||
if pass == "" {
|
||||
return nil
|
||||
}
|
||||
return s.st.Update(func(tx *bbolt.Tx) error {
|
||||
return s.st.Update(ctx, func(tx *bbolt.Tx) error {
|
||||
uRaw := s.st.GetUser(tx, username)
|
||||
u := app.User{}
|
||||
if uRaw != nil {
|
||||
@@ -240,3 +263,243 @@ func (s *AuthService) SetUserPassword(username, pass string) error {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user