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:
311
internal/app/oauth.go
Normal file
311
internal/app/oauth.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user