Files
hnh-map/internal/app/oauth.go
Nikolay Tatarinov 5ffa10f8b7 Update project structure and enhance frontend functionality
- Added a new AGENTS.md file to document the project structure and conventions.
- Updated .gitignore to include node_modules and refined cursor rules.
- Introduced new backend and frontend components for improved map interactions, including context menus and controls.
- Enhanced API composables for better admin and authentication functionalities.
- Refactored existing components for cleaner code and improved user experience.
- Updated README.md to clarify production asset serving and user setup instructions.
2026-02-25 16:32:55 +03:00

313 lines
8.5 KiB
Go

package app
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/andyleap/hnh-map/internal/app/store"
"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(store.BucketOAuthStates)
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(store.BucketOAuthStates)
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 := "/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(store.BucketUsers)
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(store.BucketUsers)
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)
}