Implement OAuth login functionality and enhance documentation

- Added support for Google OAuth login, including new API endpoints for OAuth providers and callbacks.
- Updated user authentication logic to handle OAuth-only users.
- Enhanced README.md and deployment documentation with OAuth setup instructions.
- Modified frontend components to include OAuth login options and improved error handling.
- Updated configuration files to include new environment variables for OAuth integration.
This commit is contained in:
2026-02-25 00:26:38 +03:00
parent 051719381a
commit 2c7bf48719
14 changed files with 470 additions and 29 deletions

View File

@@ -37,6 +37,13 @@ func (a *App) apiLogin(rw http.ResponseWriter, req *http.Request) {
http.Error(rw, "bad request", http.StatusBadRequest)
return
}
// OAuth-only users cannot login with password
if uByName := a.getUserByUsername(body.User); uByName != nil && uByName.Pass == nil {
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(rw).Encode(map[string]string{"error": "Use OAuth to sign in"})
return
}
u := a.getUser(body.User, body.Pass)
if u == nil {
// Bootstrap: first admin via env HNHMAP_BOOTSTRAP_PASSWORD when no users exist
@@ -682,6 +689,27 @@ func (a *App) apiRouter(rw http.ResponseWriter, req *http.Request) {
}
switch {
case path == "oauth/providers":
a.apiOAuthProviders(rw, req)
return
case strings.HasPrefix(path, "oauth/"):
rest := strings.TrimPrefix(path, "oauth/")
parts := strings.SplitN(rest, "/", 2)
if len(parts) != 2 {
http.Error(rw, "not found", http.StatusNotFound)
return
}
provider := parts[0]
action := parts[1]
switch action {
case "login":
a.oauthLogin(rw, req, provider)
case "callback":
a.oauthCallback(rw, req, provider)
default:
http.Error(rw, "not found", http.StatusNotFound)
}
return
case path == "setup":
a.apiSetup(rw, req)
return

View File

@@ -131,6 +131,8 @@ type User struct {
Pass []byte
Auths Auths
Tokens []string
// OAuth: provider -> subject (unique ID from provider)
OAuthLinks map[string]string `json:"oauth_links,omitempty"` // e.g. "google" -> "123456789"
}
type Page struct {

View File

@@ -100,6 +100,10 @@ func (a *App) getUser(user, pass string) (u *User) {
raw := users.Get([]byte(user))
if raw != 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

View File

@@ -156,7 +156,7 @@ var migrations = []func(tx *bbolt.Tx) error{
}
return maps.SetSequence(highest + 1)
},
func(tx *bbolt.Tx) error {
func(tx *bbolt.Tx) error {
users := tx.Bucket([]byte("users"))
if users == nil {
return nil
@@ -175,6 +175,10 @@ var migrations = []func(tx *bbolt.Tx) error{
return nil
})
},
func(tx *bbolt.Tx) error {
_, err := tx.CreateBucketIfNotExists([]byte("oauth_states"))
return err
},
}
// RunMigrations runs all pending migrations on the database.

311
internal/app/oauth.go Normal file
View File

@@ -0,0 +1,311 @@
package app
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"net/url"
"os"
"strings"
"time"
"go.etcd.io/bbolt"
"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"`
}
// oauthConfig returns OAuth2 config for the given provider, or nil if not configured.
func (a *App) 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
}
}
func (a *App) 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
}
func (a *App) oauthLogin(rw http.ResponseWriter, req *http.Request, provider string) {
if req.Method != http.MethodGet {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
baseURL := a.baseURL(req)
cfg := a.oauthConfig(provider, baseURL)
if cfg == nil {
http.Error(rw, "OAuth provider not configured", http.StatusServiceUnavailable)
return
}
state := make([]byte, 32)
if _, err := rand.Read(state); err != nil {
http.Error(rw, "internal error", http.StatusInternalServerError)
return
}
stateStr := hex.EncodeToString(state)
redirect := req.URL.Query().Get("redirect")
st := oauthState{
Provider: provider,
RedirectURI: redirect,
CreatedAt: time.Now().Unix(),
}
stRaw, _ := json.Marshal(st)
err := a.db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte("oauth_states"))
if err != nil {
return err
}
return b.Put([]byte(stateStr), stRaw)
})
if err != nil {
http.Error(rw, "internal error", http.StatusInternalServerError)
return
}
authURL := cfg.AuthCodeURL(stateStr, oauth2.AccessTypeOffline)
http.Redirect(rw, req, authURL, http.StatusFound)
}
type googleUserInfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
Name string `json:"name"`
}
func (a *App) oauthCallback(rw http.ResponseWriter, req *http.Request, provider string) {
if req.Method != http.MethodGet {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
code := req.URL.Query().Get("code")
state := req.URL.Query().Get("state")
if code == "" || state == "" {
http.Error(rw, "missing code or state", http.StatusBadRequest)
return
}
baseURL := a.baseURL(req)
cfg := a.oauthConfig(provider, baseURL)
if cfg == nil {
http.Error(rw, "OAuth provider not configured", http.StatusServiceUnavailable)
return
}
var st oauthState
err := a.db.Update(func(tx *bbolt.Tx) error {
b := tx.Bucket([]byte("oauth_states"))
if b == nil {
return nil
}
raw := b.Get([]byte(state))
if raw == nil {
return nil
}
json.Unmarshal(raw, &st)
return b.Delete([]byte(state))
})
if err != nil || st.Provider == "" {
http.Error(rw, "invalid or expired state", http.StatusBadRequest)
return
}
if time.Since(time.Unix(st.CreatedAt, 0)) > oauthStateTTL {
http.Error(rw, "state expired", http.StatusBadRequest)
return
}
if st.Provider != provider {
http.Error(rw, "state mismatch", http.StatusBadRequest)
return
}
tok, err := cfg.Exchange(req.Context(), code)
if err != nil {
http.Error(rw, "OAuth exchange failed", http.StatusBadRequest)
return
}
var sub, email string
switch provider {
case "google":
sub, email, err = a.googleUserInfo(tok.AccessToken)
if err != nil {
http.Error(rw, "failed to get user info", http.StatusInternalServerError)
return
}
default:
http.Error(rw, "unsupported provider", http.StatusBadRequest)
return
}
username, _ := a.findOrCreateOAuthUser(provider, sub, email)
if username == "" {
http.Error(rw, "internal error", http.StatusInternalServerError)
return
}
sessionID := a.createSession(username, false)
if sessionID == "" {
http.Error(rw, "internal error", http.StatusInternalServerError)
return
}
http.SetCookie(rw, &http.Cookie{
Name: "session",
Value: sessionID,
Path: "/",
MaxAge: 24 * 7 * 3600,
HttpOnly: true,
Secure: req.TLS != nil,
SameSite: http.SameSiteLaxMode,
})
redirectTo := "/map/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
}
}
}
http.Redirect(rw, req, redirectTo, http.StatusFound)
}
func (a *App) googleUserInfo(accessToken string) (sub, email string, err error) {
req, err := http.NewRequest("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 "", "", err
}
var info googleUserInfo
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
return "", "", err
}
return info.Sub, info.Email, nil
}
// findOrCreateOAuthUser finds user by oauth_links[provider]==sub, or creates new user.
// Returns (username, nil) or ("", err) on error.
func (a *App) findOrCreateOAuthUser(provider, sub, email string) (string, *User) {
var username string
err := a.db.Update(func(tx *bbolt.Tx) error {
users, err := tx.CreateBucketIfNotExists([]byte("users"))
if err != nil {
return err
}
// Search by OAuth link
_ = users.ForEach(func(k, v []byte) error {
user := User{}
if json.Unmarshal(v, &user) != nil {
return nil
}
if user.OAuthLinks != nil && user.OAuthLinks[provider] == sub {
username = string(k)
return nil
}
return nil
})
if username != "" {
// Update OAuthLinks if needed
raw := users.Get([]byte(username))
if raw != nil {
user := 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)
users.Put([]byte(username), raw)
}
return nil
}
// Create new user
username = email
if username == "" {
username = provider + "_" + sub
}
// Check if username already exists (e.g. local user with same email)
if users.Get([]byte(username)) != nil {
username = provider + "_" + sub
}
newUser := &User{
Pass: nil,
Auths: Auths{AUTH_MAP, AUTH_MARKERS, AUTH_UPLOAD},
OAuthLinks: map[string]string{provider: sub},
}
raw, _ := json.Marshal(newUser)
return users.Put([]byte(username), raw)
})
if err != nil {
return "", nil
}
return username, nil
}
// getUserByUsername returns user without password check (for OAuth-only check).
func (a *App) getUserByUsername(username string) *User {
var u *User
a.db.View(func(tx *bbolt.Tx) error {
users := tx.Bucket([]byte("users"))
if users == nil {
return nil
}
raw := users.Get([]byte(username))
if raw != nil {
json.Unmarshal(raw, &u)
}
return nil
})
return u
}
// apiOAuthProviders returns list of configured OAuth providers.
func (a *App) apiOAuthProviders(rw http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
http.Error(rw, "method not allowed", http.StatusMethodNotAllowed)
return
}
var providers []string
if os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_ID") != "" && os.Getenv("HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET") != "" {
providers = append(providers, "google")
}
rw.Header().Set("Content-Type", "application/json")
json.NewEncoder(rw).Encode(providers)
}