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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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": ["...", ...]}`.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
Пример для первого запуска:
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
@@ -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<MeResponse>
|
||||
}
|
||||
|
||||
/** 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<string[]> {
|
||||
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,
|
||||
|
||||
@@ -4,6 +4,18 @@
|
||||
<div class="card-body">
|
||||
<h1 class="card-title justify-center text-2xl">HnH Map</h1>
|
||||
<p class="text-center text-base-content/70 text-sm">Log in to continue</p>
|
||||
<div v-if="(oauthProviders ?? []).length" class="flex flex-col gap-2">
|
||||
<a
|
||||
v-for="p in (oauthProviders ?? [])"
|
||||
:key="p"
|
||||
:href="api.oauthLoginUrl(p, redirect || undefined)"
|
||||
class="btn btn-outline gap-2"
|
||||
>
|
||||
<span v-if="p === 'google'">Login with Google</span>
|
||||
<span v-else>Login with {{ p }}</span>
|
||||
</a>
|
||||
<div class="divider text-sm">or</div>
|
||||
</div>
|
||||
<form @submit.prevent="submit" class="flex flex-col gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="user">User</label>
|
||||
@@ -40,15 +52,23 @@ const user = ref('')
|
||||
const pass = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const oauthProviders = ref<string[]>([])
|
||||
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 {
|
||||
|
||||
10
go.mod
10
go.mod
@@ -6,5 +6,13 @@ 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
|
||||
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
|
||||
)
|
||||
|
||||
15
go.sum
15
go.sum
@@ -1,5 +1,7 @@
|
||||
github.com/coreos/bbolt v1.3.3 h1:n6AiVyVRKQFNb6mJlwESEvvLoDyiTzXX7ORAUlkeBdY=
|
||||
github.com/coreos/bbolt v1.3.3/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
|
||||
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=
|
||||
@@ -7,9 +9,18 @@ golang.org/x/crypto v0.0.0-20200302210943-78000ba7a073 h1:xMPOj6Pz6UipU1wXLkrtqp
|
||||
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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
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