mirror of
https://github.com/m1k1o/neko.git
synced 2026-11-03 00:11:48 -06:00
feat(auth): add OIDC member authentication
This commit is contained in:
@@ -12,7 +12,8 @@
|
|||||||
backgroundColor: Background(seed),
|
backgroundColor: Background(seed),
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
{{ seed.substring(0, 2).toUpperCase() }}
|
<img v-if="imageURL" :src="imageURL" :alt="seed" @error="imageFailed = true" />
|
||||||
|
<template v-else>{{ seed.substring(0, 2).toUpperCase() }}</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -25,19 +26,47 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Vue, Component, Prop } from 'vue-property-decorator'
|
import { Vue, Component, Prop, Watch } from 'vue-property-decorator'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
name: 'neko-avatar',
|
name: 'neko-avatar',
|
||||||
})
|
})
|
||||||
export default class extends Vue {
|
export default class extends Vue {
|
||||||
@Prop(String) readonly seed: string | undefined
|
@Prop(String) readonly seed: string | undefined
|
||||||
|
@Prop(String) readonly avatar: string | undefined
|
||||||
@Prop(Number) readonly size: number | undefined
|
@Prop(Number) readonly size: number | undefined
|
||||||
|
|
||||||
|
private imageFailed: boolean = false
|
||||||
|
|
||||||
|
get imageURL(): string {
|
||||||
|
if (this.imageFailed || !this.avatar) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const avatar = new URL(this.avatar)
|
||||||
|
return avatar.protocol === 'http:' || avatar.protocol === 'https:' ? avatar.toString() : ''
|
||||||
|
} catch (_) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Watch('avatar')
|
||||||
|
onAvatarChanged() {
|
||||||
|
this.imageFailed = false
|
||||||
|
}
|
||||||
|
|
||||||
Background(seed: string) {
|
Background(seed: string) {
|
||||||
let a = 0,
|
let a = 0,
|
||||||
b = 0,
|
b = 0,
|
||||||
|
|||||||
@@ -11,7 +11,12 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="author" @contextmenu.stop.prevent="onContext($event, { member: member(message.id) })">
|
<div class="author" @contextmenu.stop.prevent="onContext($event, { member: member(message.id) })">
|
||||||
<neko-avatar class="avatar" :seed="member(message.id).displayname" :size="40" />
|
<neko-avatar
|
||||||
|
class="avatar"
|
||||||
|
:seed="member(message.id).displayname"
|
||||||
|
:avatar="member(message.id).avatar"
|
||||||
|
:size="40"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="content-head">
|
<div class="content-head">
|
||||||
|
|||||||
@@ -8,11 +8,30 @@
|
|||||||
<form class="message" v-if="!connecting" @submit.stop.prevent="connect">
|
<form class="message" v-if="!connecting" @submit.stop.prevent="connect">
|
||||||
<span v-if="!autoPassword">{{ $t('connect.login_title') }}</span>
|
<span v-if="!autoPassword">{{ $t('connect.login_title') }}</span>
|
||||||
<span v-else>{{ $t('connect.invitation_title') }}</span>
|
<span v-else>{{ $t('connect.invitation_title') }}</span>
|
||||||
<input type="text" :placeholder="$t('connect.displayname')" v-model="displayname" autofocus />
|
<input
|
||||||
<input type="password" :placeholder="$t('connect.password')" v-model="password" v-if="!autoPassword" />
|
v-if="passwordLoginEnabled"
|
||||||
<button type="submit" @click.stop.prevent="login">
|
type="text"
|
||||||
|
:placeholder="$t('connect.displayname')"
|
||||||
|
v-model="displayname"
|
||||||
|
autofocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-if="passwordLoginEnabled && !autoPassword"
|
||||||
|
type="password"
|
||||||
|
:placeholder="$t('connect.password')"
|
||||||
|
v-model="password"
|
||||||
|
/>
|
||||||
|
<button v-if="passwordLoginEnabled" type="submit" @click.stop.prevent="login">
|
||||||
{{ $t('connect.connect') }}
|
{{ $t('connect.connect') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="oauthEnabled && !autoPassword"
|
||||||
|
type="button"
|
||||||
|
class="oauth-login"
|
||||||
|
@click.stop.prevent="loginWithOAuth"
|
||||||
|
>
|
||||||
|
{{ oauthName }} Login
|
||||||
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div class="loader" v-if="connecting">
|
<div class="loader" v-if="connecting">
|
||||||
<div class="bounce1"></div>
|
<div class="bounce1"></div>
|
||||||
@@ -101,6 +120,11 @@
|
|||||||
line-height: 30px;
|
line-height: 30px;
|
||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
||||||
|
&.oauth-login {
|
||||||
|
background: $background-tertiary;
|
||||||
|
border: 1px solid $style-primary;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,8 +179,16 @@
|
|||||||
|
|
||||||
private displayname: string = ''
|
private displayname: string = ''
|
||||||
private password: string = ''
|
private password: string = ''
|
||||||
|
private oauthEnabled: boolean = false
|
||||||
|
private oauthName: string = 'OAuth'
|
||||||
|
private oauthLoginURL: string = ''
|
||||||
|
private passwordLoginEnabled: boolean = true
|
||||||
|
|
||||||
|
async mounted() {
|
||||||
|
if (await this.loadOAuthConfig()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
mounted() {
|
|
||||||
// auto-password fill
|
// auto-password fill
|
||||||
let password = this.$accessor.password
|
let password = this.$accessor.password
|
||||||
if (this.autoPassword !== null) {
|
if (this.autoPassword !== null) {
|
||||||
@@ -182,6 +214,36 @@
|
|||||||
return this.$accessor.connecting
|
return this.$accessor.connecting
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadOAuthConfig(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await this.$http.get('api/oauth/config')
|
||||||
|
const config = response.data
|
||||||
|
this.oauthEnabled = config.enabled === true
|
||||||
|
this.oauthName = config.name || 'OAuth'
|
||||||
|
this.oauthLoginURL = config.login_url || ''
|
||||||
|
this.passwordLoginEnabled = config.password_login_enabled !== false
|
||||||
|
|
||||||
|
if (this.oauthEnabled && !this.passwordLoginEnabled) {
|
||||||
|
try {
|
||||||
|
await this.$http.get('api/whoami')
|
||||||
|
this.$accessor.login({ displayname: '', password: '' })
|
||||||
|
} catch (_) {
|
||||||
|
// No authenticated session yet; leave the OAuth button visible.
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// An unauthenticated OAuth user remains on the OAuth login screen.
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
loginWithOAuth() {
|
||||||
|
if (this.oauthLoginURL !== '') {
|
||||||
|
window.location.assign(this.oauthLoginURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
removeUrlParam(param: string) {
|
removeUrlParam(param: string) {
|
||||||
let url = document.location.href
|
let url = document.location.href
|
||||||
let urlparts = url.split('?')
|
let urlparts = url.split('?')
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
<template slot-scope="child" v-if="child.data">
|
<template slot-scope="child" v-if="child.data">
|
||||||
<li class="header">
|
<li class="header">
|
||||||
<div class="user">
|
<div class="user">
|
||||||
<neko-avatar class="avatar" :seed="child.data.member.displayname" :size="25" />
|
<neko-avatar
|
||||||
|
class="avatar"
|
||||||
|
:seed="child.data.member.displayname"
|
||||||
|
:avatar="child.data.member.avatar"
|
||||||
|
:size="25"
|
||||||
|
/>
|
||||||
<strong>{{ child.data.member.displayname }}</strong>
|
<strong>{{ child.data.member.displayname }}</strong>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<ul class="members-list">
|
<ul class="members-list">
|
||||||
<li v-if="member">
|
<li v-if="member">
|
||||||
<div :class="[{ host: member.id === host }, 'self', 'member']">
|
<div :class="[{ host: member.id === host }, 'self', 'member']">
|
||||||
<neko-avatar class="avatar" :seed="member.displayname" :size="50" />
|
<neko-avatar class="avatar" :seed="member.displayname" :avatar="member.avatar" :size="50" />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<template v-for="(member, index) in members">
|
<template v-for="(member, index) in members">
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
:class="[{ host: member.id === host, admin: member.admin }, 'member']"
|
:class="[{ host: member.id === host, admin: member.admin }, 'member']"
|
||||||
@contextmenu.stop.prevent="onContext($event, { member })"
|
@contextmenu.stop.prevent="onContext($event, { member })"
|
||||||
>
|
>
|
||||||
<neko-avatar class="avatar" :seed="member.displayname" :size="50" />
|
<neko-avatar class="avatar" :seed="member.displayname" :avatar="member.avatar" :size="50" />
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface Member {
|
export interface Member {
|
||||||
id: string
|
id: string
|
||||||
displayname: string
|
displayname: string
|
||||||
|
avatar?: string
|
||||||
admin: boolean
|
admin: boolean
|
||||||
muted: boolean
|
muted: boolean
|
||||||
connected?: boolean
|
connected?: boolean
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ func (c *serve) Start(cmd *cobra.Command) {
|
|||||||
c.managers.member,
|
c.managers.member,
|
||||||
c.managers.desktop,
|
c.managers.desktop,
|
||||||
c.managers.capture,
|
c.managers.capture,
|
||||||
|
&c.configs.Member,
|
||||||
|
&c.configs.Server,
|
||||||
)
|
)
|
||||||
|
|
||||||
c.managers.plugins = plugins.New(
|
c.managers.plugins = plugins.New(
|
||||||
@@ -201,6 +203,7 @@ func (c *serve) Start(cmd *cobra.Command) {
|
|||||||
c.managers.webSocket,
|
c.managers.webSocket,
|
||||||
c.managers.api,
|
c.managers.api,
|
||||||
&c.configs.Server,
|
&c.configs.Server,
|
||||||
|
&c.configs.Member,
|
||||||
)
|
)
|
||||||
c.managers.http.Start()
|
c.managers.http.Start()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,489 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/m1k1o/neko/server/internal/config"
|
||||||
|
"github.com/m1k1o/neko/server/pkg/types"
|
||||||
|
"github.com/m1k1o/neko/server/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
const oauthStateLifetime = 10 * time.Minute
|
||||||
|
|
||||||
|
type oauthState struct {
|
||||||
|
verifier string
|
||||||
|
redirectURL string
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type oauthToken struct {
|
||||||
|
accessToken string
|
||||||
|
idTokenClaims map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
type OAuthUIConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
LoginURL string `json:"login_url"`
|
||||||
|
PasswordLoginEnabled bool `json:"password_login_enabled"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type oauthHandler struct {
|
||||||
|
config config.OAuth
|
||||||
|
client *http.Client
|
||||||
|
pathPrefix string
|
||||||
|
trustProxy bool
|
||||||
|
providerOAuth bool
|
||||||
|
configMu sync.Mutex
|
||||||
|
issuerResolved bool
|
||||||
|
statesMu sync.Mutex
|
||||||
|
states map[string]oauthState
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOAuthHandler(config config.OAuth, pathPrefix string, trustProxy bool, providerOAuth bool) *oauthHandler {
|
||||||
|
if pathPrefix == "" {
|
||||||
|
pathPrefix = "/"
|
||||||
|
}
|
||||||
|
return &oauthHandler{
|
||||||
|
config: config,
|
||||||
|
client: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
pathPrefix: pathPrefix,
|
||||||
|
trustProxy: trustProxy,
|
||||||
|
providerOAuth: providerOAuth,
|
||||||
|
states: make(map[string]oauthState),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *oauthHandler) configured(config config.OAuth) bool {
|
||||||
|
return handler.providerOAuth && config.Enabled && config.ClientID != "" && config.ClientSecret != "" &&
|
||||||
|
config.AuthorizationURL != "" && config.TokenURL != "" && config.UserInfoURL != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *ApiManagerCtx) OAuthConfig(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
config := api.oauth.config
|
||||||
|
name := strings.TrimSpace(config.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = "OAuth"
|
||||||
|
}
|
||||||
|
|
||||||
|
return utils.HttpSuccess(w, OAuthUIConfig{
|
||||||
|
Enabled: api.oauth.providerOAuth && config.Enabled,
|
||||||
|
Name: name,
|
||||||
|
LoginURL: path.Join(api.oauth.pathPrefix, "/api/oauth/login"),
|
||||||
|
PasswordLoginEnabled: !api.oauth.providerOAuth,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type openIDConfiguration struct {
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||||
|
TokenEndpoint string `json:"token_endpoint"`
|
||||||
|
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveConfig uses OpenID Connect discovery when an issuer is configured.
|
||||||
|
// Issuer metadata deliberately takes precedence over explicitly configured endpoints.
|
||||||
|
func (handler *oauthHandler) resolveConfig(ctx context.Context) (config.OAuth, error) {
|
||||||
|
handler.configMu.Lock()
|
||||||
|
defer handler.configMu.Unlock()
|
||||||
|
|
||||||
|
if handler.config.IssuerURL == "" || handler.issuerResolved {
|
||||||
|
return handler.config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
discoveryURL, err := openIDConfigurationURL(handler.config.IssuerURL)
|
||||||
|
if err != nil {
|
||||||
|
return config.OAuth{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return config.OAuth{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
response, err := handler.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return config.OAuth{}, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return config.OAuth{}, fmt.Errorf("issuer discovery endpoint returned %s", response.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var metadata openIDConfiguration
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&metadata); err != nil {
|
||||||
|
return config.OAuth{}, err
|
||||||
|
}
|
||||||
|
if metadata.Issuer != handler.config.IssuerURL {
|
||||||
|
return config.OAuth{}, fmt.Errorf("issuer discovery response issuer does not match configured issuer")
|
||||||
|
}
|
||||||
|
for field, endpoint := range map[string]string{
|
||||||
|
"authorization_endpoint": metadata.AuthorizationEndpoint,
|
||||||
|
"token_endpoint": metadata.TokenEndpoint,
|
||||||
|
"userinfo_endpoint": metadata.UserInfoEndpoint,
|
||||||
|
} {
|
||||||
|
if !isAbsoluteHTTPURL(endpoint) {
|
||||||
|
return config.OAuth{}, fmt.Errorf("issuer discovery response has invalid %s", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.config.AuthorizationURL = metadata.AuthorizationEndpoint
|
||||||
|
handler.config.TokenURL = metadata.TokenEndpoint
|
||||||
|
handler.config.UserInfoURL = metadata.UserInfoEndpoint
|
||||||
|
handler.issuerResolved = true
|
||||||
|
return handler.config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func openIDConfigurationURL(issuerURL string) (string, error) {
|
||||||
|
issuer, err := url.Parse(issuerURL)
|
||||||
|
if err != nil || !isAbsoluteHTTPURL(issuerURL) {
|
||||||
|
return "", errors.New("OAuth issuer URL must be an absolute HTTP(S) URL")
|
||||||
|
}
|
||||||
|
issuer.Path = strings.TrimSuffix(issuer.Path, "/") + "/.well-known/openid-configuration"
|
||||||
|
issuer.RawQuery = ""
|
||||||
|
issuer.Fragment = ""
|
||||||
|
return issuer.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isAbsoluteHTTPURL(value string) bool {
|
||||||
|
endpoint, err := url.Parse(value)
|
||||||
|
return err == nil && endpoint.Host != "" && (endpoint.Scheme == "http" || endpoint.Scheme == "https")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *oauthHandler) callbackURL(r *http.Request) (string, error) {
|
||||||
|
scheme := "http"
|
||||||
|
if r.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
host := r.Host
|
||||||
|
if handler.trustProxy {
|
||||||
|
if forwardedProto := forwardedHeaderValue(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" {
|
||||||
|
scheme = forwardedProto
|
||||||
|
}
|
||||||
|
if forwardedHost := forwardedHeaderValue(r.Header.Get("X-Forwarded-Host")); forwardedHost != "" {
|
||||||
|
host = forwardedHost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if host == "" || (scheme != "http" && scheme != "https") {
|
||||||
|
return "", errors.New("request has no valid public URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
callback := &url.URL{
|
||||||
|
Scheme: scheme,
|
||||||
|
Host: host,
|
||||||
|
Path: path.Join(handler.pathPrefix, "/api/oauth/callback"),
|
||||||
|
}
|
||||||
|
if !isAbsoluteHTTPURL(callback.String()) {
|
||||||
|
return "", errors.New("request has no valid public URL")
|
||||||
|
}
|
||||||
|
return callback.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func forwardedHeaderValue(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.Split(value, ",")[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *ApiManagerCtx) OAuthLogin(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
if !api.oauth.providerOAuth || !api.oauth.config.Enabled {
|
||||||
|
return utils.HttpNotFound()
|
||||||
|
}
|
||||||
|
config, err := api.oauth.resolveConfig(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return utils.HttpError(http.StatusServiceUnavailable, "OAuth issuer discovery failed").WithInternalErr(err)
|
||||||
|
}
|
||||||
|
if !api.oauth.configured(config) {
|
||||||
|
return utils.HttpError(http.StatusServiceUnavailable, "OAuth is not fully configured")
|
||||||
|
}
|
||||||
|
if !api.sessions.CookieEnabled() {
|
||||||
|
return utils.HttpError(http.StatusServiceUnavailable, "OAuth requires session cookies to be enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := newOAuthToken(32)
|
||||||
|
if err != nil {
|
||||||
|
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||||
|
}
|
||||||
|
verifier, err := newOAuthToken(64)
|
||||||
|
if err != nil {
|
||||||
|
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectURL := config.RedirectURL
|
||||||
|
if redirectURL == "" {
|
||||||
|
redirectURL, err = api.oauth.callbackURL(r)
|
||||||
|
if err != nil {
|
||||||
|
return utils.HttpInternalServerError("unable to determine OAuth callback URL").WithInternalErr(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.oauth.storeState(state, verifier, redirectURL)
|
||||||
|
|
||||||
|
authorizationURL, err := url.Parse(config.AuthorizationURL)
|
||||||
|
if err != nil {
|
||||||
|
return utils.HttpInternalServerError("invalid OAuth authorization URL").WithInternalErr(err)
|
||||||
|
}
|
||||||
|
query := authorizationURL.Query()
|
||||||
|
query.Set("response_type", "code")
|
||||||
|
query.Set("client_id", config.ClientID)
|
||||||
|
query.Set("redirect_uri", redirectURL)
|
||||||
|
query.Set("scope", strings.Join(config.Scopes, " "))
|
||||||
|
query.Set("state", state)
|
||||||
|
query.Set("code_challenge", oauthChallenge(verifier))
|
||||||
|
query.Set("code_challenge_method", "S256")
|
||||||
|
authorizationURL.RawQuery = query.Encode()
|
||||||
|
|
||||||
|
http.Redirect(w, r, authorizationURL.String(), http.StatusFound)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (api *ApiManagerCtx) OAuthCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
if !api.oauth.providerOAuth || !api.oauth.config.Enabled {
|
||||||
|
return utils.HttpNotFound()
|
||||||
|
}
|
||||||
|
config, err := api.oauth.resolveConfig(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
return utils.HttpError(http.StatusServiceUnavailable, "OAuth issuer discovery failed").WithInternalErr(err)
|
||||||
|
}
|
||||||
|
if !api.oauth.configured(config) {
|
||||||
|
return utils.HttpError(http.StatusServiceUnavailable, "OAuth is not fully configured")
|
||||||
|
}
|
||||||
|
if providerError := r.URL.Query().Get("error"); providerError != "" {
|
||||||
|
return utils.HttpUnauthorized("OAuth authorization was declined").WithInternalMsg(providerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
state := r.URL.Query().Get("state")
|
||||||
|
code := r.URL.Query().Get("code")
|
||||||
|
if state == "" || code == "" {
|
||||||
|
return utils.HttpBadRequest("OAuth callback is missing state or code")
|
||||||
|
}
|
||||||
|
oauthState, ok := api.oauth.popState(state)
|
||||||
|
if !ok {
|
||||||
|
return utils.HttpBadRequest("OAuth state is invalid or expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
oauthToken, err := api.oauth.exchangeCode(r, config, code, oauthState.verifier, oauthState.redirectURL)
|
||||||
|
if err != nil {
|
||||||
|
return utils.HttpUnauthorized("OAuth token exchange failed").WithInternalErr(err)
|
||||||
|
}
|
||||||
|
claims, err := api.oauth.userInfo(r, config, oauthToken.accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return utils.HttpUnauthorized("OAuth user-info request failed").WithInternalErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := oauthClaim(claims, config.SubjectField)
|
||||||
|
if subject == "" {
|
||||||
|
return utils.HttpUnauthorized("OAuth user-info response is missing the subject field")
|
||||||
|
}
|
||||||
|
if idTokenSubject := oauthClaim(oauthToken.idTokenClaims, config.SubjectField); idTokenSubject != "" && idTokenSubject != subject {
|
||||||
|
return utils.HttpUnauthorized("OAuth ID token subject does not match user-info response")
|
||||||
|
}
|
||||||
|
name := oauthClaim(claims, config.UsernameField)
|
||||||
|
if name == "" {
|
||||||
|
name = oauthClaim(oauthToken.idTokenClaims, config.UsernameField)
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = subject
|
||||||
|
}
|
||||||
|
avatar := oauthClaim(claims, config.AvatarField)
|
||||||
|
if avatar == "" {
|
||||||
|
avatar = oauthClaim(oauthToken.idTokenClaims, config.AvatarField)
|
||||||
|
}
|
||||||
|
|
||||||
|
email := oauthClaim(claims, "email")
|
||||||
|
if email == "" {
|
||||||
|
email = oauthClaim(oauthToken.idTokenClaims, "email")
|
||||||
|
}
|
||||||
|
isAdmin := oauthBoolClaim(claims, "isAdmin") || oauthBoolClaim(oauthToken.idTokenClaims, "isAdmin")
|
||||||
|
_, token, err := api.members.LoginOAuth(subject, name, avatar, email, isAdmin, mergeOAuthClaims(claims, oauthToken.idTokenClaims))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, types.ErrSessionAlreadyConnected) {
|
||||||
|
return utils.HttpUnprocessableEntity("session already connected")
|
||||||
|
}
|
||||||
|
if errors.Is(err, types.ErrSessionLoginsLocked) {
|
||||||
|
return utils.HttpForbidden("logins are locked").WithInternalErr(err)
|
||||||
|
}
|
||||||
|
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
api.sessions.CookieSetToken(w, token)
|
||||||
|
http.Redirect(w, r, api.oauth.successRedirect(), http.StatusSeeOther)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *oauthHandler) storeState(state, verifier, redirectURL string) {
|
||||||
|
handler.statesMu.Lock()
|
||||||
|
defer handler.statesMu.Unlock()
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for key, value := range handler.states {
|
||||||
|
if now.After(value.expires) {
|
||||||
|
delete(handler.states, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
handler.states[state] = oauthState{verifier: verifier, redirectURL: redirectURL, expires: now.Add(oauthStateLifetime)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *oauthHandler) popState(state string) (oauthState, bool) {
|
||||||
|
handler.statesMu.Lock()
|
||||||
|
defer handler.statesMu.Unlock()
|
||||||
|
|
||||||
|
value, ok := handler.states[state]
|
||||||
|
delete(handler.states, state)
|
||||||
|
return value, ok && time.Now().Before(value.expires)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *oauthHandler) exchangeCode(r *http.Request, config config.OAuth, code, verifier, redirectURL string) (oauthToken, error) {
|
||||||
|
values := url.Values{
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"code": {code},
|
||||||
|
"redirect_uri": {redirectURL},
|
||||||
|
"client_id": {config.ClientID},
|
||||||
|
"client_secret": {config.ClientSecret},
|
||||||
|
"code_verifier": {verifier},
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, config.TokenURL, strings.NewReader(values.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return oauthToken{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
response, err := handler.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return oauthToken{}, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return oauthToken{}, fmt.Errorf("token endpoint returned %s", response.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&payload); err != nil {
|
||||||
|
return oauthToken{}, err
|
||||||
|
}
|
||||||
|
if payload.AccessToken == "" {
|
||||||
|
return oauthToken{}, errors.New("token response has no access_token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return oauthToken{
|
||||||
|
accessToken: payload.AccessToken,
|
||||||
|
idTokenClaims: idTokenClaims(payload.IDToken),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// idTokenClaims is used only as a profile fallback when userinfo omits optional
|
||||||
|
// OIDC claims. The userinfo response remains the source of the session subject.
|
||||||
|
func idTokenClaims(idToken string) map[string]any {
|
||||||
|
parts := strings.Split(idToken, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
claims := map[string]any{}
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *oauthHandler) userInfo(r *http.Request, config config.OAuth, accessToken string) (map[string]any, error) {
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, config.UserInfoURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
response, err := handler.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices {
|
||||||
|
return nil, fmt.Errorf("user-info endpoint returned %s", response.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
claims := map[string]any{}
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&claims); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *oauthHandler) successRedirect() string {
|
||||||
|
redirect := handler.config.SuccessRedirect
|
||||||
|
if strings.HasPrefix(redirect, "/") && !strings.HasPrefix(redirect, "//") {
|
||||||
|
return path.Join(handler.pathPrefix, redirect)
|
||||||
|
}
|
||||||
|
return handler.pathPrefix
|
||||||
|
}
|
||||||
|
|
||||||
|
func newOAuthToken(bytes int) (string, error) {
|
||||||
|
buffer := make([]byte, bytes)
|
||||||
|
if _, err := rand.Read(buffer); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buffer), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func oauthChallenge(verifier string) string {
|
||||||
|
digest := sha256.Sum256([]byte(verifier))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(digest[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func oauthClaim(claims map[string]any, field string) string {
|
||||||
|
value, ok := claims[field]
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprint(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func oauthBoolClaim(claims map[string]any, field string) bool {
|
||||||
|
value, ok := claims[field]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch value := value.(type) {
|
||||||
|
case bool:
|
||||||
|
return value
|
||||||
|
case string:
|
||||||
|
return strings.EqualFold(value, "true")
|
||||||
|
case float64:
|
||||||
|
return value != 0
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeOAuthClaims(userInfo, idToken map[string]any) map[string]any {
|
||||||
|
claims := make(map[string]any, len(userInfo)+len(idToken))
|
||||||
|
for key, value := range idToken {
|
||||||
|
claims[key] = value
|
||||||
|
}
|
||||||
|
for key, value := range userInfo {
|
||||||
|
claims[key] = value
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/m1k1o/neko/server/internal/config"
|
||||||
|
"github.com/m1k1o/neko/server/internal/member"
|
||||||
|
"github.com/m1k1o/neko/server/internal/session"
|
||||||
|
"github.com/m1k1o/neko/server/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOAuthLoginSynchronizesProfile(t *testing.T) {
|
||||||
|
var tokenRequest url.Values
|
||||||
|
idToken := testIDToken(t, map[string]string{
|
||||||
|
"sub": "user-123",
|
||||||
|
"displayName": "Ada Lovelace",
|
||||||
|
"avatar": "https://example.test/ada.png",
|
||||||
|
"isAdmin": "true",
|
||||||
|
})
|
||||||
|
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/token":
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenRequest = r.Form
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "provider-token", "id_token": idToken})
|
||||||
|
case "/userinfo":
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer provider-token" {
|
||||||
|
http.Error(w, "missing provider authorization", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"sub": "user-123",
|
||||||
|
"email": "user@example.test",
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer provider.Close()
|
||||||
|
|
||||||
|
memberConfig := &config.Member{
|
||||||
|
Provider: "oauth",
|
||||||
|
OAuth: config.OAuth{
|
||||||
|
Enabled: true,
|
||||||
|
ClientID: "client-id",
|
||||||
|
ClientSecret: "client-secret",
|
||||||
|
AuthorizationURL: provider.URL + "/authorize",
|
||||||
|
TokenURL: provider.URL + "/token",
|
||||||
|
UserInfoURL: provider.URL + "/userinfo",
|
||||||
|
RedirectURL: "https://neko.example.test/api/oauth/callback",
|
||||||
|
Scopes: []string{"openid", "profile"},
|
||||||
|
SubjectField: "sub",
|
||||||
|
UsernameField: "displayName",
|
||||||
|
AvatarField: "avatar",
|
||||||
|
AdminEmails: []string{"admin@example.test"},
|
||||||
|
SuccessRedirect: "/room",
|
||||||
|
UserProfile: types.MemberProfile{
|
||||||
|
CanLogin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sessionManager := session.New(&config.Session{
|
||||||
|
Cookie: config.SessionCookie{Enabled: true, Name: "NEKO_SESSION", Expiration: time.Hour},
|
||||||
|
})
|
||||||
|
members := member.New(sessionManager, memberConfig)
|
||||||
|
api := New(sessionManager, members, nil, nil, memberConfig, &config.Server{PathPrefix: "/"})
|
||||||
|
|
||||||
|
loginRecorder := httptest.NewRecorder()
|
||||||
|
api.OAuthLogin(loginRecorder, httptest.NewRequest(http.MethodGet, "/api/oauth/login", nil))
|
||||||
|
if loginRecorder.Code != http.StatusFound {
|
||||||
|
t.Fatalf("login status = %d", loginRecorder.Code)
|
||||||
|
}
|
||||||
|
authorizeURL, err := url.Parse(loginRecorder.Header().Get("Location"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
state := authorizeURL.Query().Get("state")
|
||||||
|
if state == "" || authorizeURL.Query().Get("code_challenge") == "" {
|
||||||
|
t.Fatalf("OAuth redirect is missing state or PKCE challenge: %s", authorizeURL.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackRecorder := httptest.NewRecorder()
|
||||||
|
callbackURL := "/api/oauth/callback?state=" + url.QueryEscape(state) + "&code=authorization-code"
|
||||||
|
api.OAuthCallback(callbackRecorder, httptest.NewRequest(http.MethodGet, callbackURL, nil))
|
||||||
|
if callbackRecorder.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("callback status = %d", callbackRecorder.Code)
|
||||||
|
}
|
||||||
|
if got := callbackRecorder.Header().Get("Location"); got != "/room" {
|
||||||
|
t.Fatalf("redirect = %q", got)
|
||||||
|
}
|
||||||
|
if tokenRequest.Get("code_verifier") == "" {
|
||||||
|
t.Fatal("token request is missing PKCE verifier")
|
||||||
|
}
|
||||||
|
cookies := callbackRecorder.Result().Cookies()
|
||||||
|
if len(cookies) != 1 {
|
||||||
|
t.Fatal("OAuth callback did not set a session cookie")
|
||||||
|
}
|
||||||
|
if cookies[0].Path != "/" {
|
||||||
|
t.Fatalf("OAuth session cookie path = %q, want root path", cookies[0].Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
userSession, ok := sessionManager.Get("oauth:user-123")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("OAuth session was not created")
|
||||||
|
}
|
||||||
|
profile := userSession.Profile()
|
||||||
|
if profile.Name != "Ada Lovelace" || profile.Avatar != "https://example.test/ada.png" || !profile.IsAdmin {
|
||||||
|
t.Fatalf("profile = %#v", profile)
|
||||||
|
}
|
||||||
|
if extraData := members.OAuthExtraData(userSession.ID()); extraData["displayName"] != "Ada Lovelace" || extraData["isAdmin"] != "true" {
|
||||||
|
t.Fatalf("extra data = %#v", extraData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testIDToken(t *testing.T, claims map[string]string) string {
|
||||||
|
t.Helper()
|
||||||
|
payload, err := json.Marshal(claims)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return "eyJhbGciOiJub25lIn0." + base64.RawURLEncoding.EncodeToString(payload) + ".signature"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOAuthIssuerDiscoveryTakesPrecedence(t *testing.T) {
|
||||||
|
var tokenRequest url.Values
|
||||||
|
var issuerURL string
|
||||||
|
issuer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/.well-known/openid-configuration":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"issuer": issuerURL,
|
||||||
|
"authorization_endpoint": issuerURL + "/authorize",
|
||||||
|
"token_endpoint": issuerURL + "/token",
|
||||||
|
"userinfo_endpoint": issuerURL + "/userinfo",
|
||||||
|
})
|
||||||
|
case "/token":
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenRequest = r.Form
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "provider-token"})
|
||||||
|
case "/userinfo":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"sub": "user-456", "name": "Grace Hopper"})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer issuer.Close()
|
||||||
|
|
||||||
|
issuerURL = issuer.URL
|
||||||
|
memberConfig := &config.Member{
|
||||||
|
Provider: "oauth",
|
||||||
|
OAuth: config.OAuth{
|
||||||
|
Enabled: true,
|
||||||
|
ClientID: "client-id",
|
||||||
|
ClientSecret: "client-secret",
|
||||||
|
IssuerURL: issuerURL,
|
||||||
|
AuthorizationURL: "https://ignored.example.test/authorize",
|
||||||
|
TokenURL: "https://ignored.example.test/token",
|
||||||
|
UserInfoURL: "https://ignored.example.test/userinfo",
|
||||||
|
Scopes: []string{"openid", "profile"},
|
||||||
|
SubjectField: "sub",
|
||||||
|
UsernameField: "name",
|
||||||
|
AvatarField: "picture",
|
||||||
|
UserProfile: types.MemberProfile{CanLogin: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionManager := session.New(&config.Session{
|
||||||
|
Cookie: config.SessionCookie{Enabled: true, Name: "NEKO_SESSION", Expiration: time.Hour},
|
||||||
|
})
|
||||||
|
members := member.New(sessionManager, memberConfig)
|
||||||
|
api := New(sessionManager, members, nil, nil, memberConfig, &config.Server{PathPrefix: "/"})
|
||||||
|
|
||||||
|
loginRequest := httptest.NewRequest(http.MethodGet, "https://neko.example.test/api/oauth/login", nil)
|
||||||
|
loginRecorder := httptest.NewRecorder()
|
||||||
|
api.OAuthLogin(loginRecorder, loginRequest)
|
||||||
|
if loginRecorder.Code != http.StatusFound {
|
||||||
|
t.Fatalf("login status = %d", loginRecorder.Code)
|
||||||
|
}
|
||||||
|
authorizationURL, err := url.Parse(loginRecorder.Header().Get("Location"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
issuerEndpoint, err := url.Parse(issuerURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if authorizationURL.Host != issuerEndpoint.Host {
|
||||||
|
t.Fatalf("authorization endpoint = %q", authorizationURL.String())
|
||||||
|
}
|
||||||
|
redirectURL := authorizationURL.Query().Get("redirect_uri")
|
||||||
|
if redirectURL != "https://neko.example.test/api/oauth/callback" {
|
||||||
|
t.Fatalf("derived redirect URL = %q", redirectURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
callbackRecorder := httptest.NewRecorder()
|
||||||
|
callbackURL := "/api/oauth/callback?state=" + url.QueryEscape(authorizationURL.Query().Get("state")) + "&code=authorization-code"
|
||||||
|
api.OAuthCallback(callbackRecorder, httptest.NewRequest(http.MethodGet, callbackURL, nil))
|
||||||
|
if callbackRecorder.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("callback status = %d", callbackRecorder.Code)
|
||||||
|
}
|
||||||
|
if tokenRequest.Get("redirect_uri") != redirectURL {
|
||||||
|
t.Fatalf("token redirect URL = %q, want %q", tokenRequest.Get("redirect_uri"), redirectURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOAuthConfigUsesProviderAndName(t *testing.T) {
|
||||||
|
memberConfig := &config.Member{
|
||||||
|
Provider: "oauth",
|
||||||
|
OAuth: config.OAuth{
|
||||||
|
Enabled: true,
|
||||||
|
Name: "Team SSO",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sessionManager := session.New(&config.Session{})
|
||||||
|
api := New(sessionManager, member.New(sessionManager, memberConfig), nil, nil, memberConfig, &config.Server{})
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
if err := api.OAuthConfig(recorder, httptest.NewRequest(http.MethodGet, "/api/oauth/config", nil)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload OAuthUIConfig
|
||||||
|
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !payload.Enabled || payload.PasswordLoginEnabled || payload.Name != "Team SSO" || payload.LoginURL != "/api/oauth/login" {
|
||||||
|
t.Fatalf("OAuth UI config = %#v", payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/m1k1o/neko/server/internal/api/members"
|
"github.com/m1k1o/neko/server/internal/api/members"
|
||||||
"github.com/m1k1o/neko/server/internal/api/room"
|
"github.com/m1k1o/neko/server/internal/api/room"
|
||||||
"github.com/m1k1o/neko/server/internal/api/sessions"
|
"github.com/m1k1o/neko/server/internal/api/sessions"
|
||||||
|
"github.com/m1k1o/neko/server/internal/config"
|
||||||
"github.com/m1k1o/neko/server/pkg/auth"
|
"github.com/m1k1o/neko/server/pkg/auth"
|
||||||
"github.com/m1k1o/neko/server/pkg/types"
|
"github.com/m1k1o/neko/server/pkg/types"
|
||||||
"github.com/m1k1o/neko/server/pkg/utils"
|
"github.com/m1k1o/neko/server/pkg/utils"
|
||||||
@@ -19,6 +20,7 @@ type ApiManagerCtx struct {
|
|||||||
desktop types.DesktopManager
|
desktop types.DesktopManager
|
||||||
capture types.CaptureManager
|
capture types.CaptureManager
|
||||||
routers map[string]func(types.Router)
|
routers map[string]func(types.Router)
|
||||||
|
oauth *oauthHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
@@ -26,7 +28,13 @@ func New(
|
|||||||
members types.MemberManager,
|
members types.MemberManager,
|
||||||
desktop types.DesktopManager,
|
desktop types.DesktopManager,
|
||||||
capture types.CaptureManager,
|
capture types.CaptureManager,
|
||||||
|
memberConfig *config.Member,
|
||||||
|
serverConfig *config.Server,
|
||||||
) *ApiManagerCtx {
|
) *ApiManagerCtx {
|
||||||
|
pathPrefix := "/"
|
||||||
|
if serverConfig != nil {
|
||||||
|
pathPrefix = serverConfig.PathPrefix
|
||||||
|
}
|
||||||
|
|
||||||
return &ApiManagerCtx{
|
return &ApiManagerCtx{
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
@@ -34,11 +42,15 @@ func New(
|
|||||||
desktop: desktop,
|
desktop: desktop,
|
||||||
capture: capture,
|
capture: capture,
|
||||||
routers: make(map[string]func(types.Router)),
|
routers: make(map[string]func(types.Router)),
|
||||||
|
oauth: newOAuthHandler(memberConfig.OAuth, pathPrefix, serverConfig != nil && serverConfig.Proxy, memberConfig.Provider == "oauth"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *ApiManagerCtx) Route(r types.Router) {
|
func (api *ApiManagerCtx) Route(r types.Router) {
|
||||||
r.Post("/login", api.Login)
|
r.Post("/login", api.Login)
|
||||||
|
r.Get("/oauth/config", api.OAuthConfig)
|
||||||
|
r.Get("/oauth/login", api.OAuthLogin)
|
||||||
|
r.Get("/oauth/callback", api.OAuthCallback)
|
||||||
|
|
||||||
// Authenticated area
|
// Authenticated area
|
||||||
r.Group(func(r types.Router) {
|
r.Group(func(r types.Router) {
|
||||||
@@ -65,6 +77,11 @@ func (api *ApiManagerCtx) Route(r types.Router) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (api *ApiManagerCtx) IsAuthenticated(r *http.Request) bool {
|
||||||
|
_, err := api.sessions.Authenticate(r)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
func (api *ApiManagerCtx) Authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error) {
|
func (api *ApiManagerCtx) Authenticate(w http.ResponseWriter, r *http.Request) (context.Context, error) {
|
||||||
session, err := api.sessions.Authenticate(r)
|
session, err := api.sessions.Authenticate(r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ type SessionLoginPayload struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SessionDataPayload struct {
|
type SessionDataPayload struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Token string `json:"token,omitempty"`
|
Token string `json:"token,omitempty"`
|
||||||
Profile types.MemberProfile `json:"profile"`
|
Profile types.MemberProfile `json:"profile"`
|
||||||
State types.SessionState `json:"state"`
|
State types.SessionState `json:"state"`
|
||||||
|
ExtraData map[string]any `json:"extra_data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (api *ApiManagerCtx) Login(w http.ResponseWriter, r *http.Request) error {
|
func (api *ApiManagerCtx) Login(w http.ResponseWriter, r *http.Request) error {
|
||||||
@@ -78,9 +79,10 @@ func (api *ApiManagerCtx) Whoami(w http.ResponseWriter, r *http.Request) error {
|
|||||||
session, _ := auth.GetSession(r)
|
session, _ := auth.GetSession(r)
|
||||||
|
|
||||||
return utils.HttpSuccess(w, SessionDataPayload{
|
return utils.HttpSuccess(w, SessionDataPayload{
|
||||||
ID: session.ID(),
|
ID: session.ID(),
|
||||||
Profile: session.Profile(),
|
Profile: session.Profile(),
|
||||||
State: session.State(),
|
State: session.State(),
|
||||||
|
ExtraData: api.members.OAuthExtraData(session.ID()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -19,6 +21,29 @@ type Member struct {
|
|||||||
File file.Config
|
File file.Config
|
||||||
Object object.Config
|
Object object.Config
|
||||||
Multiuser multiuser.Config
|
Multiuser multiuser.Config
|
||||||
|
OAuth OAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth contains the generic OAuth 2.0 authorization-code flow settings.
|
||||||
|
// The provider's user-info response is mapped to a Neko member profile.
|
||||||
|
type OAuth struct {
|
||||||
|
Enabled bool
|
||||||
|
AutoRedirect bool
|
||||||
|
Name string
|
||||||
|
AdminEmails []string
|
||||||
|
ClientID string
|
||||||
|
ClientSecret string
|
||||||
|
IssuerURL string
|
||||||
|
AuthorizationURL string
|
||||||
|
TokenURL string
|
||||||
|
UserInfoURL string
|
||||||
|
RedirectURL string
|
||||||
|
Scopes []string
|
||||||
|
SubjectField string
|
||||||
|
UsernameField string
|
||||||
|
AvatarField string
|
||||||
|
SuccessRedirect string
|
||||||
|
UserProfile types.MemberProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Member) Init(cmd *cobra.Command) error {
|
func (Member) Init(cmd *cobra.Command) error {
|
||||||
@@ -65,6 +90,92 @@ func (Member) Init(cmd *cobra.Command) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OAuth 2.0 provider
|
||||||
|
cmd.PersistentFlags().Bool("member.oauth.enabled", false, "enable OAuth 2.0 login")
|
||||||
|
if err := viper.BindPFlag("member.oauth.enabled", cmd.PersistentFlags().Lookup("member.oauth.enabled")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().Bool("member.oauth.auto_redirect", false, "redirect the Neko root page to OAuth login automatically")
|
||||||
|
if err := viper.BindPFlag("member.oauth.auto_redirect", cmd.PersistentFlags().Lookup("member.oauth.auto_redirect")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().String("member.oauth.name", "OAuth", "display name for the OAuth login option")
|
||||||
|
if err := viper.BindPFlag("member.oauth.name", cmd.PersistentFlags().Lookup("member.oauth.name")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().String("member.oauth.admin_email", "", "comma-separated OAuth user-info email addresses granted administrator access")
|
||||||
|
if err := viper.BindPFlag("member.oauth.admin_email", cmd.PersistentFlags().Lookup("member.oauth.admin_email")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().String("member.oauth.client_id", "", "OAuth 2.0 client ID")
|
||||||
|
if err := viper.BindPFlag("member.oauth.client_id", cmd.PersistentFlags().Lookup("member.oauth.client_id")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().String("member.oauth.client_secret", "", "OAuth 2.0 client secret")
|
||||||
|
if err := viper.BindPFlag("member.oauth.client_secret", cmd.PersistentFlags().Lookup("member.oauth.client_secret")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().String("member.oauth.issuer_url", "", "OpenID Connect issuer URL used to discover OAuth endpoints")
|
||||||
|
if err := viper.BindPFlag("member.oauth.issuer_url", cmd.PersistentFlags().Lookup("member.oauth.issuer_url")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().String("member.oauth.authorization_url", "", "OAuth 2.0 authorization endpoint")
|
||||||
|
if err := viper.BindPFlag("member.oauth.authorization_url", cmd.PersistentFlags().Lookup("member.oauth.authorization_url")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().String("member.oauth.token_url", "", "OAuth 2.0 token endpoint")
|
||||||
|
if err := viper.BindPFlag("member.oauth.token_url", cmd.PersistentFlags().Lookup("member.oauth.token_url")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().String("member.oauth.userinfo_url", "", "OAuth 2.0 user-info endpoint")
|
||||||
|
if err := viper.BindPFlag("member.oauth.userinfo_url", cmd.PersistentFlags().Lookup("member.oauth.userinfo_url")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().String("member.oauth.redirect_url", "", "OAuth 2.0 callback URL registered with the provider")
|
||||||
|
if err := viper.BindPFlag("member.oauth.redirect_url", cmd.PersistentFlags().Lookup("member.oauth.redirect_url")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().StringSlice("member.oauth.scopes", []string{"openid", "profile"}, "OAuth 2.0 scopes")
|
||||||
|
if err := viper.BindPFlag("member.oauth.scopes", cmd.PersistentFlags().Lookup("member.oauth.scopes")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().String("member.oauth.subject_field", "sub", "field in user-info response used as the stable user identifier")
|
||||||
|
if err := viper.BindPFlag("member.oauth.subject_field", cmd.PersistentFlags().Lookup("member.oauth.subject_field")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().String("member.oauth.username_field", "name", "field in user-info response used as the display name")
|
||||||
|
if err := viper.BindPFlag("member.oauth.username_field", cmd.PersistentFlags().Lookup("member.oauth.username_field")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().String("member.oauth.avatar_field", "picture", "field in user-info response used as the avatar URL")
|
||||||
|
if err := viper.BindPFlag("member.oauth.avatar_field", cmd.PersistentFlags().Lookup("member.oauth.avatar_field")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().String("member.oauth.success_redirect", "/", "relative URL to redirect to after OAuth login")
|
||||||
|
if err := viper.BindPFlag("member.oauth.success_redirect", cmd.PersistentFlags().Lookup("member.oauth.success_redirect")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.PersistentFlags().String("member.oauth.user_profile", "{}", "OAuth user permission profile")
|
||||||
|
if err := viper.BindPFlag("member.oauth.user_profile", cmd.PersistentFlags().Lookup("member.oauth.user_profile")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +211,24 @@ func (s *Member) Set() {
|
|||||||
s.Multiuser.UserPassword = viper.GetString("member.multiuser.user_password")
|
s.Multiuser.UserPassword = viper.GetString("member.multiuser.user_password")
|
||||||
s.Multiuser.AdminPassword = viper.GetString("member.multiuser.admin_password")
|
s.Multiuser.AdminPassword = viper.GetString("member.multiuser.admin_password")
|
||||||
|
|
||||||
|
// OAuth provider
|
||||||
|
s.OAuth.Enabled = viper.GetBool("member.oauth.enabled")
|
||||||
|
s.OAuth.AutoRedirect = viper.GetBool("member.oauth.auto_redirect")
|
||||||
|
s.OAuth.Name = viper.GetString("member.oauth.name")
|
||||||
|
s.OAuth.AdminEmails = commaSeparatedValues(viper.GetString("member.oauth.admin_email"))
|
||||||
|
s.OAuth.ClientID = viper.GetString("member.oauth.client_id")
|
||||||
|
s.OAuth.ClientSecret = viper.GetString("member.oauth.client_secret")
|
||||||
|
s.OAuth.IssuerURL = viper.GetString("member.oauth.issuer_url")
|
||||||
|
s.OAuth.AuthorizationURL = viper.GetString("member.oauth.authorization_url")
|
||||||
|
s.OAuth.TokenURL = viper.GetString("member.oauth.token_url")
|
||||||
|
s.OAuth.UserInfoURL = viper.GetString("member.oauth.userinfo_url")
|
||||||
|
s.OAuth.RedirectURL = viper.GetString("member.oauth.redirect_url")
|
||||||
|
s.OAuth.Scopes = viper.GetStringSlice("member.oauth.scopes")
|
||||||
|
s.OAuth.SubjectField = viper.GetString("member.oauth.subject_field")
|
||||||
|
s.OAuth.UsernameField = viper.GetString("member.oauth.username_field")
|
||||||
|
s.OAuth.AvatarField = viper.GetString("member.oauth.avatar_field")
|
||||||
|
s.OAuth.SuccessRedirect = viper.GetString("member.oauth.success_redirect")
|
||||||
|
|
||||||
// default user profile
|
// default user profile
|
||||||
s.Multiuser.UserProfile = types.MemberProfile{
|
s.Multiuser.UserProfile = types.MemberProfile{
|
||||||
IsAdmin: false,
|
IsAdmin: false,
|
||||||
@@ -112,6 +241,7 @@ func (s *Member) Set() {
|
|||||||
SendsInactiveCursor: true,
|
SendsInactiveCursor: true,
|
||||||
CanSeeInactiveCursors: false,
|
CanSeeInactiveCursors: false,
|
||||||
}
|
}
|
||||||
|
s.OAuth.UserProfile = s.Multiuser.UserProfile
|
||||||
|
|
||||||
// override user profile
|
// override user profile
|
||||||
if err := viper.UnmarshalKey("member.multiuser.user_profile", &s.Multiuser.UserProfile, viper.DecodeHook(
|
if err := viper.UnmarshalKey("member.multiuser.user_profile", &s.Multiuser.UserProfile, viper.DecodeHook(
|
||||||
@@ -119,6 +249,11 @@ func (s *Member) Set() {
|
|||||||
)); err != nil {
|
)); err != nil {
|
||||||
log.Warn().Err(err).Msgf("unable to parse member multiuser user profile")
|
log.Warn().Err(err).Msgf("unable to parse member multiuser user profile")
|
||||||
}
|
}
|
||||||
|
if err := viper.UnmarshalKey("member.oauth.user_profile", &s.OAuth.UserProfile, viper.DecodeHook(
|
||||||
|
utils.JsonStringAutoDecode(s.OAuth.UserProfile),
|
||||||
|
)); err != nil {
|
||||||
|
log.Warn().Err(err).Msgf("unable to parse member OAuth user profile")
|
||||||
|
}
|
||||||
|
|
||||||
// default admin profile
|
// default admin profile
|
||||||
s.Multiuser.AdminProfile = types.MemberProfile{
|
s.Multiuser.AdminProfile = types.MemberProfile{
|
||||||
@@ -141,6 +276,17 @@ func (s *Member) Set() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func commaSeparatedValues(value string) []string {
|
||||||
|
values := strings.Split(value, ",")
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
if value = strings.TrimSpace(value); value != "" {
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Member) SetV2() {
|
func (s *Member) SetV2() {
|
||||||
enableLegacy := false
|
enableLegacy := false
|
||||||
|
|
||||||
|
|||||||
@@ -105,7 +105,14 @@ func (h *LegacyHandler) Route(r types.Router) {
|
|||||||
defer s.destroy()
|
defer s.destroy()
|
||||||
|
|
||||||
// dial to the remote backend
|
// dial to the remote backend
|
||||||
connBackend, _, err := h.wsDialer.Dial("ws://"+h.serverAddr+path.Join(s.pathPrefix, "/api/ws")+"?token="+url.QueryEscape(s.token), nil)
|
backendURL := "ws://" + h.serverAddr + path.Join(s.pathPrefix, "/api/ws")
|
||||||
|
backendHeaders := make(http.Header)
|
||||||
|
if s.token != "" {
|
||||||
|
backendURL += "?token=" + url.QueryEscape(s.token)
|
||||||
|
} else {
|
||||||
|
backendHeaders.Set("Cookie", s.cookie)
|
||||||
|
}
|
||||||
|
connBackend, _, err := h.wsDialer.Dial(backendURL, backendHeaders)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error().Err(err).Msg("couldn't dial to the remote backend")
|
h.logger.Error().Err(err).Msg("couldn't dial to the remote backend")
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ type session struct {
|
|||||||
|
|
||||||
id, ip string
|
id, ip string
|
||||||
token string
|
token string
|
||||||
|
cookie string
|
||||||
name string
|
name string
|
||||||
isAdmin bool
|
isAdmin bool
|
||||||
client *http.Client
|
client *http.Client
|
||||||
@@ -85,6 +86,8 @@ func (s *session) req(method, reqPath string, headers http.Header, request io.Re
|
|||||||
|
|
||||||
if s.token != "" {
|
if s.token != "" {
|
||||||
req.Header.Set("Authorization", "Bearer "+s.token)
|
req.Header.Set("Authorization", "Bearer "+s.token)
|
||||||
|
} else if s.cookie != "" {
|
||||||
|
req.Header.Set("Cookie", s.cookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := s.client.Do(req)
|
res, err := s.client.Do(req)
|
||||||
@@ -111,14 +114,19 @@ func (s *session) req(method, reqPath string, headers http.Header, request io.Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *session) apiReq(method, path string, request, response any) error {
|
func (s *session) apiReq(method, path string, request, response any) error {
|
||||||
|
return s.apiReqWithHeaders(method, path, request, response, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *session) apiReqWithHeaders(method, path string, request, response any, headers http.Header) error {
|
||||||
reqBody, err := json.Marshal(request)
|
reqBody, err := json.Marshal(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
headers := http.Header{
|
if headers == nil {
|
||||||
"Content-Type": []string{"application/json"},
|
headers = make(http.Header)
|
||||||
}
|
}
|
||||||
|
headers.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
resBody, _, err := s.req(method, path, headers, bytes.NewReader(reqBody))
|
resBody, _, err := s.req(method, path, headers, bytes.NewReader(reqBody))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -174,6 +182,16 @@ func (s *session) toBackend(event string, payload any) error {
|
|||||||
|
|
||||||
func (s *session) create(username, password string) error {
|
func (s *session) create(username, password string) error {
|
||||||
data := api.SessionDataPayload{}
|
data := api.SessionDataPayload{}
|
||||||
|
if cookie := s.r.Header.Get("Cookie"); cookie != "" {
|
||||||
|
err := s.apiReqWithHeaders(http.MethodGet, "/api/whoami", nil, &data, http.Header{
|
||||||
|
"Cookie": []string{cookie},
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
s.cookie = cookie
|
||||||
|
s.setSessionData(data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err := s.apiReq(http.MethodPost, "/api/login", api.SessionLoginPayload{
|
err := s.apiReq(http.MethodPost, "/api/login", api.SessionLoginPayload{
|
||||||
Username: username,
|
Username: username,
|
||||||
@@ -183,13 +201,10 @@ func (s *session) create(username, password string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.id, s.ip = data.ID, getIp(s.r)
|
s.setSessionData(data)
|
||||||
s.h.sessionIPs[s.id] = s.ip // save session ip by id
|
|
||||||
s.token = data.Token
|
|
||||||
s.name = data.Profile.Name
|
|
||||||
s.isAdmin = data.Profile.IsAdmin
|
|
||||||
|
|
||||||
// if Cookie auth, the token will be empty
|
// Cookie-based authentication is supported when the browser presents an
|
||||||
|
// existing session above. Password login still requires an API token.
|
||||||
if s.token == "" {
|
if s.token == "" {
|
||||||
return fmt.Errorf("token not found - make sure you are not using Cookie auth on the server")
|
return fmt.Errorf("token not found - make sure you are not using Cookie auth on the server")
|
||||||
}
|
}
|
||||||
@@ -197,6 +212,14 @@ func (s *session) create(username, password string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *session) setSessionData(data api.SessionDataPayload) {
|
||||||
|
s.id, s.ip = data.ID, getIp(s.r)
|
||||||
|
s.h.sessionIPs[s.id] = s.ip // save session ip by id
|
||||||
|
s.token = data.Token
|
||||||
|
s.name = data.Profile.Name
|
||||||
|
s.isAdmin = data.Profile.IsAdmin
|
||||||
|
}
|
||||||
|
|
||||||
func (s *session) destroy() {
|
func (s *session) destroy() {
|
||||||
defer s.client.CloseIdleConnections()
|
defer s.client.CloseIdleConnections()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package legacy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/m1k1o/neko/server/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCreateUsesExistingSessionCookie(t *testing.T) {
|
||||||
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/whoami":
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
t.Fatalf("method = %s, want GET", r.Method)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Cookie"); got != "NEKO_SESSION=session-token" {
|
||||||
|
t.Fatalf("cookie = %q", got)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"id": "oauth:user-123",
|
||||||
|
"profile": types.MemberProfile{
|
||||||
|
Name: "Ada Lovelace",
|
||||||
|
IsAdmin: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case "/api/protected":
|
||||||
|
if got := r.Header.Get("Cookie"); got != "NEKO_SESSION=session-token" {
|
||||||
|
t.Fatalf("authenticated request cookie = %q", got)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(true)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer backend.Close()
|
||||||
|
|
||||||
|
request := httptest.NewRequest(http.MethodGet, "http://neko.test/ws", nil)
|
||||||
|
request.Header.Set("Cookie", "NEKO_SESSION=session-token")
|
||||||
|
handler := &LegacyHandler{sessionIPs: make(map[string]string)}
|
||||||
|
session := &session{
|
||||||
|
r: request,
|
||||||
|
h: handler,
|
||||||
|
serverAddr: strings.TrimPrefix(backend.URL, "http://"),
|
||||||
|
client: backend.Client(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.create("", ""); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if session.id != "oauth:user-123" || session.name != "Ada Lovelace" || !session.isAdmin {
|
||||||
|
t.Fatalf("session = %#v", session)
|
||||||
|
}
|
||||||
|
if session.cookie != "NEKO_SESSION=session-token" || session.token != "" {
|
||||||
|
t.Fatalf("authentication state = cookie %q token %q", session.cookie, session.token)
|
||||||
|
}
|
||||||
|
if err := session.apiReq(http.MethodGet, "/api/protected", nil, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,10 +19,11 @@ type Stats struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Member struct {
|
type Member struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"displayname"`
|
Name string `json:"displayname"`
|
||||||
Admin bool `json:"admin"`
|
Avatar string `json:"avatar,omitempty"`
|
||||||
Muted bool `json:"muted"`
|
Admin bool `json:"admin"`
|
||||||
|
Muted bool `json:"muted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileListItem struct {
|
type FileListItem struct {
|
||||||
|
|||||||
@@ -32,10 +32,11 @@ func profileToMember(id string, profile types.MemberProfile) (*oldTypes.Member,
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &oldTypes.Member{
|
return &oldTypes.Member{
|
||||||
ID: id,
|
ID: id,
|
||||||
Name: profile.Name,
|
Name: profile.Name,
|
||||||
Admin: profile.IsAdmin,
|
Avatar: profile.Avatar,
|
||||||
Muted: !settings.CanSend,
|
Admin: profile.IsAdmin,
|
||||||
|
Muted: !settings.CanSend,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -618,18 +619,17 @@ func (s *session) wsToClient(msg []byte) error {
|
|||||||
|
|
||||||
// Open In App Events
|
// Open In App Events
|
||||||
case openinapp.OPENINAPP_INIT:
|
case openinapp.OPENINAPP_INIT:
|
||||||
request := &openinapp.Init{}
|
request := &openinapp.Init{}
|
||||||
if err := json.Unmarshal(data.Payload, request); err != nil {
|
if err := json.Unmarshal(data.Payload, request); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return s.toClient(&struct {
|
return s.toClient(&struct {
|
||||||
Event string `json:"event"`
|
Event string `json:"event"`
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
}{
|
}{
|
||||||
Event: openinapp.OPENINAPP_INIT,
|
Event: openinapp.OPENINAPP_INIT,
|
||||||
Enabled: request.Enabled,
|
Enabled: request.Enabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
// Screen Events
|
// Screen Events
|
||||||
case event.SCREEN_UPDATED:
|
case event.SCREEN_UPDATED:
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package legacy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/m1k1o/neko/server/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProfileToMemberPreservesAvatar(t *testing.T) {
|
||||||
|
member, err := profileToMember("oauth:user-123", types.MemberProfile{
|
||||||
|
Name: "Ada Lovelace",
|
||||||
|
Avatar: "https://example.test/ada.png",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if member.Name != "Ada Lovelace" || member.Avatar != "https://example.test/ada.png" {
|
||||||
|
t.Fatalf("member = %#v", member)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
@@ -23,7 +24,7 @@ type HttpManagerCtx struct {
|
|||||||
http *http.Server
|
http *http.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, config *config.Server) *HttpManagerCtx {
|
func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, config *config.Server, memberConfig *config.Member) *HttpManagerCtx {
|
||||||
logger := log.With().Str("module", "http").Logger()
|
logger := log.With().Str("module", "http").Logger()
|
||||||
|
|
||||||
opts := []RouterOption{
|
opts := []RouterOption{
|
||||||
@@ -67,6 +68,37 @@ func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, c
|
|||||||
}
|
}
|
||||||
router.Post("/api/batch", batch.Handle)
|
router.Post("/api/batch", batch.Handle)
|
||||||
|
|
||||||
|
var staticHandler types.RouterHandler
|
||||||
|
if config.Static != "" {
|
||||||
|
fs := http.FileServer(http.Dir(config.Static))
|
||||||
|
staticHandler = func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
_, err := os.Stat(config.Static + r.URL.Path)
|
||||||
|
if err == nil {
|
||||||
|
fs.ServeHTTP(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if memberConfig.Provider == "oauth" && memberConfig.OAuth.Enabled && memberConfig.OAuth.AutoRedirect {
|
||||||
|
router.Get("/", func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
if ApiManager.IsAuthenticated(r) {
|
||||||
|
if staticHandler != nil {
|
||||||
|
return staticHandler(w, r)
|
||||||
|
}
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, path.Join(config.PathPrefix, "/api/oauth/login"), http.StatusFound)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
router.Get("/health", func(w http.ResponseWriter, r *http.Request) error {
|
router.Get("/health", func(w http.ResponseWriter, r *http.Request) error {
|
||||||
_, err := w.Write([]byte("true"))
|
_, err := w.Write([]byte("true"))
|
||||||
return err
|
return err
|
||||||
@@ -79,20 +111,8 @@ func New(WebSocketManager types.WebSocketManager, ApiManager types.ApiManager, c
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.Static != "" {
|
if staticHandler != nil {
|
||||||
fs := http.FileServer(http.Dir(config.Static))
|
router.Get("/*", staticHandler)
|
||||||
router.Get("/*", func(w http.ResponseWriter, r *http.Request) error {
|
|
||||||
_, err := os.Stat(config.Static + r.URL.Path)
|
|
||||||
if err == nil {
|
|
||||||
fs.ServeHTTP(w, r)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.PProf {
|
if config.PProf {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/m1k1o/neko/server/internal/api"
|
||||||
|
"github.com/m1k1o/neko/server/internal/config"
|
||||||
|
"github.com/m1k1o/neko/server/internal/member"
|
||||||
|
"github.com/m1k1o/neko/server/internal/session"
|
||||||
|
"github.com/m1k1o/neko/server/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testWebSocketManager struct{}
|
||||||
|
|
||||||
|
func (testWebSocketManager) Start() {}
|
||||||
|
|
||||||
|
func (testWebSocketManager) Shutdown() error { return nil }
|
||||||
|
|
||||||
|
func (testWebSocketManager) AddHandler(types.WebSocketHandler) {}
|
||||||
|
|
||||||
|
func (testWebSocketManager) Upgrade(types.CheckOrigin) types.RouterHandler {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOAuthAutoRedirectSkipsAuthenticatedSessions(t *testing.T) {
|
||||||
|
staticDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(staticDir+"/index.html", []byte("neko client"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
memberConfig := &config.Member{Provider: "oauth", OAuth: config.OAuth{Enabled: true, AutoRedirect: true}}
|
||||||
|
sessionManager := session.New(&config.Session{
|
||||||
|
Cookie: config.SessionCookie{Enabled: true, Name: "NEKO_SESSION", Expiration: time.Hour},
|
||||||
|
})
|
||||||
|
apiManager := api.New(sessionManager, member.New(sessionManager, memberConfig), nil, nil, memberConfig, &config.Server{})
|
||||||
|
manager := New(testWebSocketManager{}, apiManager, &config.Server{Static: staticDir}, memberConfig)
|
||||||
|
|
||||||
|
unauthenticated := httptest.NewRecorder()
|
||||||
|
manager.router.ServeHTTP(unauthenticated, httptest.NewRequest(http.MethodGet, "/", nil))
|
||||||
|
if unauthenticated.Code != http.StatusFound {
|
||||||
|
t.Fatalf("unauthenticated status = %d", unauthenticated.Code)
|
||||||
|
}
|
||||||
|
if location := unauthenticated.Header().Get("Location"); location != "/api/oauth/login" {
|
||||||
|
t.Fatalf("unauthenticated location = %q", location)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, token, err := sessionManager.Create("oauth:user-123", types.MemberProfile{CanLogin: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
authenticatedRequest := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
authenticatedRequest.AddCookie(&http.Cookie{Name: "NEKO_SESSION", Value: token})
|
||||||
|
authenticated := httptest.NewRecorder()
|
||||||
|
manager.router.ServeHTTP(authenticated, authenticatedRequest)
|
||||||
|
if authenticated.Code != http.StatusOK {
|
||||||
|
t.Fatalf("authenticated status = %d", authenticated.Code)
|
||||||
|
}
|
||||||
|
if body := authenticated.Body.String(); body != "neko client" {
|
||||||
|
t.Fatalf("authenticated body = %q", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package member
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
@@ -11,15 +12,17 @@ import (
|
|||||||
"github.com/m1k1o/neko/server/internal/member/file"
|
"github.com/m1k1o/neko/server/internal/member/file"
|
||||||
"github.com/m1k1o/neko/server/internal/member/multiuser"
|
"github.com/m1k1o/neko/server/internal/member/multiuser"
|
||||||
"github.com/m1k1o/neko/server/internal/member/noauth"
|
"github.com/m1k1o/neko/server/internal/member/noauth"
|
||||||
|
oauthprovider "github.com/m1k1o/neko/server/internal/member/oauth"
|
||||||
"github.com/m1k1o/neko/server/internal/member/object"
|
"github.com/m1k1o/neko/server/internal/member/object"
|
||||||
"github.com/m1k1o/neko/server/pkg/types"
|
"github.com/m1k1o/neko/server/pkg/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(sessions types.SessionManager, config *config.Member) *MemberManagerCtx {
|
func New(sessions types.SessionManager, config *config.Member) *MemberManagerCtx {
|
||||||
manager := &MemberManagerCtx{
|
manager := &MemberManagerCtx{
|
||||||
logger: log.With().Str("module", "member").Logger(),
|
logger: log.With().Str("module", "member").Logger(),
|
||||||
sessions: sessions,
|
sessions: sessions,
|
||||||
config: config,
|
config: config,
|
||||||
|
oauthExtra: make(map[string]map[string]any),
|
||||||
}
|
}
|
||||||
|
|
||||||
switch config.Provider {
|
switch config.Provider {
|
||||||
@@ -29,6 +32,8 @@ func New(sessions types.SessionManager, config *config.Member) *MemberManagerCtx
|
|||||||
manager.provider = object.New(config.Object)
|
manager.provider = object.New(config.Object)
|
||||||
case "multiuser":
|
case "multiuser":
|
||||||
manager.provider = multiuser.New(config.Multiuser)
|
manager.provider = multiuser.New(config.Multiuser)
|
||||||
|
case "oauth":
|
||||||
|
manager.provider = oauthprovider.New()
|
||||||
case "noauth":
|
case "noauth":
|
||||||
fallthrough
|
fallthrough
|
||||||
default:
|
default:
|
||||||
@@ -45,6 +50,7 @@ type MemberManagerCtx struct {
|
|||||||
providerMu sync.Mutex
|
providerMu sync.Mutex
|
||||||
provider types.MemberProvider
|
provider types.MemberProvider
|
||||||
loginMu sync.Mutex
|
loginMu sync.Mutex
|
||||||
|
oauthExtra map[string]map[string]any
|
||||||
}
|
}
|
||||||
|
|
||||||
func (manager *MemberManagerCtx) Connect() error {
|
func (manager *MemberManagerCtx) Connect() error {
|
||||||
@@ -118,6 +124,11 @@ func (manager *MemberManagerCtx) UpdatePassword(id string, password string) erro
|
|||||||
func (manager *MemberManagerCtx) Delete(id string) error {
|
func (manager *MemberManagerCtx) Delete(id string) error {
|
||||||
manager.providerMu.Lock()
|
manager.providerMu.Lock()
|
||||||
defer manager.providerMu.Unlock()
|
defer manager.providerMu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
manager.loginMu.Lock()
|
||||||
|
defer manager.loginMu.Unlock()
|
||||||
|
delete(manager.oauthExtra, id)
|
||||||
|
}()
|
||||||
|
|
||||||
// destroy corresponding session, if exists
|
// destroy corresponding session, if exists
|
||||||
err := manager.sessions.Delete(id)
|
err := manager.sessions.Delete(id)
|
||||||
@@ -160,9 +171,89 @@ func (manager *MemberManagerCtx) Login(username string, password string) (types.
|
|||||||
return manager.sessions.Create(id, profile)
|
return manager.sessions.Create(id, profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoginOAuth creates a stable session identity from an OAuth provider subject.
|
||||||
|
// The profile data is refreshed on every successful OAuth login.
|
||||||
|
func (manager *MemberManagerCtx) LoginOAuth(subject, name, avatar, email string, isAdmin bool, extraData map[string]any) (types.Session, string, error) {
|
||||||
|
manager.loginMu.Lock()
|
||||||
|
defer manager.loginMu.Unlock()
|
||||||
|
|
||||||
|
if subject == "" {
|
||||||
|
return nil, "", errors.New("OAuth subject is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := manager.config.OAuth.UserProfile
|
||||||
|
if name != "" {
|
||||||
|
profile.Name = name
|
||||||
|
}
|
||||||
|
profile.Avatar = avatar
|
||||||
|
if isOAuthAdministrator(email, isAdmin, manager.config.OAuth.AdminEmails) {
|
||||||
|
profile.IsAdmin = true
|
||||||
|
profile.CanSeeInactiveCursors = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !profile.IsAdmin && manager.sessions.Settings().LockedLogins {
|
||||||
|
return nil, "", types.ErrSessionLoginsLocked
|
||||||
|
}
|
||||||
|
|
||||||
|
id := "oauth:" + subject
|
||||||
|
if session, ok := manager.sessions.Get(id); ok {
|
||||||
|
if session.State().IsConnected {
|
||||||
|
return nil, "", types.ErrSessionAlreadyConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := manager.sessions.Delete(id); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
session, token, err := manager.sessions.Create(id, profile)
|
||||||
|
if err == nil {
|
||||||
|
manager.oauthExtra[id] = cloneOAuthExtraData(extraData)
|
||||||
|
}
|
||||||
|
return session, token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isOAuthAdministrator(email string, isAdmin bool, adminEmails []string) bool {
|
||||||
|
if isAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
email = strings.TrimSpace(email)
|
||||||
|
if email == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, adminEmail := range adminEmails {
|
||||||
|
if strings.EqualFold(email, strings.TrimSpace(adminEmail)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (manager *MemberManagerCtx) OAuthExtraData(id string) map[string]any {
|
||||||
|
manager.loginMu.Lock()
|
||||||
|
defer manager.loginMu.Unlock()
|
||||||
|
|
||||||
|
return cloneOAuthExtraData(manager.oauthExtra[id])
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneOAuthExtraData(extraData map[string]any) map[string]any {
|
||||||
|
if len(extraData) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copy := make(map[string]any, len(extraData))
|
||||||
|
for key, value := range extraData {
|
||||||
|
copy[key] = value
|
||||||
|
}
|
||||||
|
return copy
|
||||||
|
}
|
||||||
|
|
||||||
func (manager *MemberManagerCtx) Logout(id string) error {
|
func (manager *MemberManagerCtx) Logout(id string) error {
|
||||||
manager.loginMu.Lock()
|
manager.loginMu.Lock()
|
||||||
defer manager.loginMu.Unlock()
|
defer manager.loginMu.Unlock()
|
||||||
|
|
||||||
return manager.sessions.Delete(id)
|
err := manager.sessions.Delete(id)
|
||||||
|
if err == nil || errors.Is(err, types.ErrSessionNotFound) {
|
||||||
|
delete(manager.oauthExtra, id)
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/m1k1o/neko/server/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MemberProviderCtx disables password authentication. OAuth sessions are
|
||||||
|
// created by MemberManagerCtx.LoginOAuth after the OAuth callback succeeds.
|
||||||
|
type MemberProviderCtx struct{}
|
||||||
|
|
||||||
|
func New() types.MemberProvider { return &MemberProviderCtx{} }
|
||||||
|
|
||||||
|
func (provider *MemberProviderCtx) Connect() error { return nil }
|
||||||
|
|
||||||
|
func (provider *MemberProviderCtx) Disconnect() error { return nil }
|
||||||
|
|
||||||
|
func (provider *MemberProviderCtx) Authenticate(username string, password string) (string, types.MemberProfile, error) {
|
||||||
|
return "", types.MemberProfile{}, types.ErrMemberInvalidPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *MemberProviderCtx) Insert(username string, password string, profile types.MemberProfile) (string, error) {
|
||||||
|
return "", errors.New("OAuth members are created by OAuth login")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *MemberProviderCtx) UpdateProfile(id string, profile types.MemberProfile) error {
|
||||||
|
return errors.New("OAuth profile is managed by the identity provider")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *MemberProviderCtx) UpdatePassword(id string, password string) error {
|
||||||
|
return errors.New("OAuth provider does not have passwords")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *MemberProviderCtx) Select(id string) (types.MemberProfile, error) {
|
||||||
|
return types.MemberProfile{}, errors.New("OAuth members are stored in active sessions")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string]types.MemberProfile, error) {
|
||||||
|
return map[string]types.MemberProfile{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *MemberProviderCtx) Delete(id string) error {
|
||||||
|
return errors.New("OAuth members are managed by the identity provider")
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ func (manager *SessionManagerCtx) CookieSetToken(w http.ResponseWriter, token st
|
|||||||
if manager.config.Cookie.Secure {
|
if manager.config.Cookie.Secure {
|
||||||
sameSite = http.SameSiteNoneMode
|
sameSite = http.SameSiteNoneMode
|
||||||
}
|
}
|
||||||
|
path := manager.cookiePath()
|
||||||
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: manager.config.Cookie.Name,
|
Name: manager.config.Cookie.Name,
|
||||||
@@ -23,7 +24,7 @@ func (manager *SessionManagerCtx) CookieSetToken(w http.ResponseWriter, token st
|
|||||||
SameSite: sameSite,
|
SameSite: sameSite,
|
||||||
HttpOnly: manager.config.Cookie.HTTPOnly,
|
HttpOnly: manager.config.Cookie.HTTPOnly,
|
||||||
Domain: manager.config.Cookie.Domain,
|
Domain: manager.config.Cookie.Domain,
|
||||||
Path: manager.config.Cookie.Path,
|
Path: path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,9 +36,17 @@ func (manager *SessionManagerCtx) CookieClearToken(w http.ResponseWriter, r *htt
|
|||||||
|
|
||||||
cookie.Value = ""
|
cookie.Value = ""
|
||||||
cookie.Expires = time.Unix(0, 0)
|
cookie.Expires = time.Unix(0, 0)
|
||||||
|
cookie.Path = manager.cookiePath()
|
||||||
http.SetCookie(w, cookie)
|
http.SetCookie(w, cookie)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (manager *SessionManagerCtx) cookiePath() string {
|
||||||
|
if manager.config.Cookie.Path == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return manager.config.Cookie.Path
|
||||||
|
}
|
||||||
|
|
||||||
func (manager *SessionManagerCtx) Authenticate(r *http.Request) (types.Session, error) {
|
func (manager *SessionManagerCtx) Authenticate(r *http.Request) (types.Session, error) {
|
||||||
token, ok := manager.getToken(r)
|
token, ok := manager.getToken(r)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package types
|
package types
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
type ApiManager interface {
|
type ApiManager interface {
|
||||||
Route(r Router)
|
Route(r Router)
|
||||||
AddRouter(path string, router func(Router))
|
AddRouter(path string, router func(Router))
|
||||||
|
IsAuthenticated(r *http.Request) bool
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type MemberProfile struct {
|
type MemberProfile struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
|
||||||
// permissions
|
// permissions
|
||||||
IsAdmin bool `json:"is_admin" mapstructure:"is_admin"`
|
IsAdmin bool `json:"is_admin" mapstructure:"is_admin"`
|
||||||
@@ -44,5 +45,7 @@ type MemberManager interface {
|
|||||||
MemberProvider
|
MemberProvider
|
||||||
|
|
||||||
Login(username string, password string) (Session, string, error)
|
Login(username string, password string) (Session, string, error)
|
||||||
|
LoginOAuth(subject, name, avatar, email string, isAdmin bool, extraData map[string]any) (Session, string, error)
|
||||||
|
OAuthExtraData(id string) map[string]any
|
||||||
Logout(id string) error
|
Logout(id string) error
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user