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:
2026-02-25 00:26:38 +03:00
parent 051719381a
commit 2c7bf48719
14 changed files with 470 additions and 29 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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": ["...", ...]}`.

View File

@@ -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` |
Пример для первого запуска:

View File

@@ -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/`.

View File

@@ -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,

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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.

311
internal/app/oauth.go Normal file
View 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)
}