diff --git a/.env.example b/.env.example index 8d05a61..83a3666 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,10 @@ # HNHMAP_BOOTSTRAP_PASSWORD= # Set once for first run: login as admin with this password to create the first admin user (then unset or leave empty) # Grids directory (default: grids); in Docker often /map # HNHMAP_GRIDS=grids +# OAuth (Google) — optional +# HNHMAP_BASE_URL=https://map.example.com +# HNHMAP_OAUTH_GOOGLE_CLIENT_ID= +# HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET= # Frontend (Nuxt dev) — used in docker-compose # NUXT_PUBLIC_API_BASE=/map/api diff --git a/README.md b/README.md index 4b563d9..291a6c1 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,10 @@ Only other thing you need to do is setup users and set your zero grid. First login: username **admin**, password from `HNHMAP_BOOTSTRAP_PASSWORD` (in dev Compose it defaults to `admin`). Go to the admin portal and hit "ADD USER". Don't forget to toggle on all the roles (you'll need admin, at least) +### OAuth (Google) + +To enable "Login with Google", set `HNHMAP_OAUTH_GOOGLE_CLIENT_ID`, `HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET`, and `HNHMAP_BASE_URL` (your app's full URL). Create OAuth credentials in Google Cloud Console and add the callback URL `{HNHMAP_BASE_URL}/map/api/oauth/google/callback` to Authorized redirect URIs. See [deployment.md](docs/deployment.md) for details. + Once you create your first user, you'll get kicked out and have to log in as it. The admin user will be gone at this point. Next you'll want to add users for anyone else, and then you'll need to create your tokens to upload stuff. diff --git a/docs/api.md b/docs/api.md index c015773..d812a58 100644 --- a/docs/api.md +++ b/docs/api.md @@ -4,11 +4,17 @@ API доступно по префиксу `/map/api/`. Для запросов, ## Авторизация -- **POST /map/api/login** — вход. Тело: `{"user":"...","pass":"..."}`. При успехе возвращается JSON с данными пользователя и выставляется cookie сессии. При первом запуске возможен bootstrap: логин `admin` и пароль из `HNHMAP_BOOTSTRAP_PASSWORD` создают первого админа. +- **POST /map/api/login** — вход. Тело: `{"user":"...","pass":"..."}`. При успехе возвращается JSON с данными пользователя и выставляется cookie сессии. При первом запуске возможен bootstrap: логин `admin` и пароль из `HNHMAP_BOOTSTRAP_PASSWORD` создают первого админа. Для пользователей, созданных через OAuth (без пароля), возвращается 401 с `{"error":"Use OAuth to sign in"}`. - **GET /map/api/me** — текущий пользователь (по сессии). Ответ: `username`, `auths`, при необходимости `tokens`, `prefix`. - **POST /map/api/logout** — выход (инвалидация сессии). - **GET /map/api/setup** — проверка, нужна ли первичная настройка. Ответ: `{"setupRequired": true|false}`. +### OAuth + +- **GET /map/api/oauth/providers** — список настроенных OAuth-провайдеров. Ответ: `["google", ...]`. +- **GET /map/api/oauth/{provider}/login** — редирект на страницу авторизации провайдера. Query: `redirect` — путь для редиректа после успешного входа (например `/map/profile`). +- **GET /map/api/oauth/{provider}/callback** — callback от провайдера (вызывается автоматически). Обменивает `code` на токены, создаёт или находит пользователя, создаёт сессию и редиректит на `/map/profile` или `redirect` из state. + ## Кабинет - **POST /map/api/me/tokens** — сгенерировать новый токен загрузки (требуется право `upload`). Ответ: `{"tokens": ["...", ...]}`. diff --git a/docs/configuration.md b/docs/configuration.md index 3ac764d..1bc69e9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -7,6 +7,9 @@ | `HNHMAP_PORT` | Порт HTTP-сервера | 8080 | | `-port` | То же (флаг командной строки) | значение `HNHMAP_PORT` или 8080 | | `HNHMAP_BOOTSTRAP_PASSWORD` | Пароль для первой настройки: при отсутствии пользователей вход как `admin` с этим паролем создаёт первого админа | — | +| `HNHMAP_BASE_URL` | Полный URL приложения для OAuth redirect_uri (например `https://map.example.com`). Если не задан, берётся из `Host` и `X-Forwarded-*` | — | +| `HNHMAP_OAUTH_GOOGLE_CLIENT_ID` | Google OAuth Client ID | — | +| `HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET` | Google OAuth Client Secret | — | | `-grids` | Каталог гридов (флаг командной строки; в Docker обычно `-grids=/map`) | `grids` | Пример для первого запуска: diff --git a/docs/deployment.md b/docs/deployment.md index e7e1582..0fb7d97 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -21,6 +21,18 @@ docker run -v /srv/hnh-map:/map -p 8080:8080 \ Рекомендуется после первой настройки убрать или не передавать `HNHMAP_BOOTSTRAP_PASSWORD`. +## OAuth (Google) + +Для входа через Google OAuth: + +1. Создайте проект в [Google Cloud Console](https://console.cloud.google.com/). +2. Включите «Google+ API» / «Google Identity» и создайте OAuth 2.0 Client ID (тип «Web application»). +3. В настройках клиента добавьте Authorized redirect URI: `https://your-domain.com/map/api/oauth/google/callback` (замените на ваш домен). +4. Задайте переменные окружения: + - `HNHMAP_OAUTH_GOOGLE_CLIENT_ID` — Client ID + - `HNHMAP_OAUTH_GOOGLE_CLIENT_SECRET` — Client Secret + - `HNHMAP_BASE_URL` — полный URL приложения (например `https://map.example.com`) для формирования redirect_uri. Если не задан, берётся из `Host` и `X-Forwarded-*` заголовков. + ## Reverse proxy Разместите сервис за nginx, Traefik, Caddy и т.п. на нужном домене. Проксируйте весь трафик на порт 8080 контейнера (или тот порт, на котором слушает приложение). Отдельная настройка для `/map` не обязательна: приложение само отдаёт SPA и API по путям `/map/`, `/map/api/`, `/map/updates`, `/map/grids/`. diff --git a/frontend-nuxt/composables/useMapApi.ts b/frontend-nuxt/composables/useMapApi.ts index 1c68bc6..8d67e24 100644 --- a/frontend-nuxt/composables/useMapApi.ts +++ b/frontend-nuxt/composables/useMapApi.ts @@ -53,11 +53,33 @@ export function useMapApi() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user, pass }), }) - if (res.status === 401) throw new Error('Unauthorized') + if (res.status === 401) { + const data = (await res.json().catch(() => ({}))) as { error?: string } + throw new Error(data.error || 'Unauthorized') + } if (!res.ok) throw new Error(`API ${res.status}`) return res.json() as Promise } + /** OAuth login URL for redirect (full page navigation). */ + function oauthLoginUrl(provider: string, redirect?: string): string { + const url = new URL(`${apiBase}/oauth/${provider}/login`) + if (redirect) url.searchParams.set('redirect', redirect) + return url.toString() + } + + /** List of configured OAuth providers. */ + async function oauthProviders(): Promise { + try { + const res = await fetch(`${apiBase}/oauth/providers`, { credentials: 'include' }) + if (!res.ok) return [] + const data = await res.json() + return Array.isArray(data) ? data : [] + } catch { + return [] + } + } + async function logout() { await fetch(`${apiBase}/logout`, { method: 'POST', credentials: 'include' }) } @@ -183,6 +205,8 @@ export function useMapApi() { login, logout, me, + oauthLoginUrl, + oauthProviders, setupRequired, meTokens, mePassword, diff --git a/frontend-nuxt/pages/login.vue b/frontend-nuxt/pages/login.vue index 614d041..4020c12 100644 --- a/frontend-nuxt/pages/login.vue +++ b/frontend-nuxt/pages/login.vue @@ -4,6 +4,18 @@

HnH Map

Log in to continue

+
@@ -40,15 +52,23 @@ const user = ref('') const pass = ref('') const error = ref('') const loading = ref(false) +const oauthProviders = ref([]) const router = useRouter() +const route = useRoute() const api = useMapApi() +const redirect = computed(() => (route.query.redirect as string) || '') + +onMounted(async () => { + oauthProviders.value = await api.oauthProviders() +}) + async function submit() { error.value = '' loading.value = true try { await api.login(user.value, pass.value) - await router.push('/profile') + await router.push(redirect.value || '/profile') } catch (e: unknown) { error.value = e instanceof Error ? e.message : 'Login failed' } finally { diff --git a/go.mod b/go.mod index e7a112d..74583b8 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,18 @@ -module github.com/andyleap/hnh-map - -go 1.21 - -require ( - go.etcd.io/bbolt v1.3.3 - golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 - golang.org/x/image v0.0.0-20200119044424-58c23975cae1 - golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 // indirect -) +module github.com/andyleap/hnh-map + +go 1.21 + +require ( + go.etcd.io/bbolt v1.3.3 + golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 + golang.org/x/image v0.0.0-20200119044424-58c23975cae1 + golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d +) + +require ( + cloud.google.com/go v0.34.0 // indirect + github.com/golang/protobuf v1.2.0 // indirect + golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 // indirect + golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 // indirect + google.golang.org/appengine v1.4.0 // indirect +) diff --git a/go.sum b/go.sum index c21c176..c0d1380 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,26 @@ -github.com/coreos/bbolt v1.3.3 h1:n6AiVyVRKQFNb6mJlwESEvvLoDyiTzXX7ORAUlkeBdY= -github.com/coreos/bbolt v1.3.3/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= -go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk= -go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM= -golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/image v0.0.0-20200119044424-58c23975cae1 h1:5h3ngYt7+vXCDZCup/HkCQgW5XwmSvR/nA2JmJ0RErg= -golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So= -golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +cloud.google.com/go v0.34.0 h1:eOI3/cP2VTU6uZLDYAoic+eyzzB9YyGmJ7eIjl8rOPg= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk= +go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqpHbR0AVFnyPEQq/wRWz9lM= +golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/image v0.0.0-20200119044424-58c23975cae1 h1:5h3ngYt7+vXCDZCup/HkCQgW5XwmSvR/nA2JmJ0RErg= +golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3 h1:0GoQqolDA55aaLxZyTzK/Y2ePZzZTUrRacwib7cNsYQ= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d h1:TzXSXBo42m9gQenoE3b9BGiEpg5IG2JkU5FkPIawgtw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527 h1:uYVVQ9WP/Ds2ROhcaGPeIdVq0RIXVLwsHlnvJ+cT1So= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= diff --git a/internal/app/api.go b/internal/app/api.go index 18a3e0a..f54ac90 100644 --- a/internal/app/api.go +++ b/internal/app/api.go @@ -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 diff --git a/internal/app/app.go b/internal/app/app.go index 0ad469e..948faa9 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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 { diff --git a/internal/app/auth.go b/internal/app/auth.go index e614699..197ed6d 100644 --- a/internal/app/auth.go +++ b/internal/app/auth.go @@ -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 diff --git a/internal/app/migrations.go b/internal/app/migrations.go index 75deabf..49e8656 100644 --- a/internal/app/migrations.go +++ b/internal/app/migrations.go @@ -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. diff --git a/internal/app/oauth.go b/internal/app/oauth.go new file mode 100644 index 0000000..2cc2d8f --- /dev/null +++ b/internal/app/oauth.go @@ -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) +}