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) }