mirror of
https://github.com/m1k1o/neko.git
synced 2026-11-03 00:11:48 -06:00
refactor(auth): move OAuth flow into member provider
This commit is contained in:
+68
-421
@@ -1,39 +1,17 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/m1k1o/neko/server/internal/config"
|
"github.com/m1k1o/neko/server/internal/member/oauth"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
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 {
|
type OAuthUIConfig struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -41,126 +19,86 @@ type OAuthUIConfig struct {
|
|||||||
PasswordLoginEnabled bool `json:"password_login_enabled"`
|
PasswordLoginEnabled bool `json:"password_login_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// oauthHandler is deliberately limited to HTTP concerns. The OAuth protocol,
|
||||||
|
// profile mapping, and session creation are owned by member/oauth.Service.
|
||||||
type oauthHandler struct {
|
type oauthHandler struct {
|
||||||
config config.OAuth
|
service *oauth.Service
|
||||||
client *http.Client
|
pathPrefix string
|
||||||
pathPrefix string
|
trustProxy bool
|
||||||
trustProxy bool
|
providerOAuth 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 {
|
func newOAuthHandler(service *oauth.Service, pathPrefix string, trustProxy, providerOAuth bool) *oauthHandler {
|
||||||
if pathPrefix == "" {
|
if pathPrefix == "" {
|
||||||
pathPrefix = "/"
|
pathPrefix = "/"
|
||||||
}
|
}
|
||||||
return &oauthHandler{
|
return &oauthHandler{service: service, pathPrefix: pathPrefix, trustProxy: trustProxy, providerOAuth: providerOAuth}
|
||||||
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 {
|
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{
|
return utils.HttpSuccess(w, OAuthUIConfig{
|
||||||
Enabled: api.oauth.providerOAuth && config.Enabled,
|
Enabled: api.oauth.providerOAuth && api.oauth.service.Enabled(),
|
||||||
Name: name,
|
Name: api.oauth.service.Name(),
|
||||||
LoginURL: path.Join(api.oauth.pathPrefix, "/api/oauth/login"),
|
LoginURL: path.Join(api.oauth.pathPrefix, "/api/oauth/login"),
|
||||||
PasswordLoginEnabled: !api.oauth.providerOAuth,
|
PasswordLoginEnabled: !api.oauth.providerOAuth,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type openIDConfiguration struct {
|
func (api *ApiManagerCtx) OAuthLogin(w http.ResponseWriter, r *http.Request) error {
|
||||||
Issuer string `json:"issuer"`
|
if !api.oauth.providerOAuth || !api.oauth.service.Enabled() {
|
||||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
return utils.HttpNotFound()
|
||||||
TokenEndpoint string `json:"token_endpoint"`
|
}
|
||||||
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
if !api.sessions.CookieEnabled() {
|
||||||
|
return utils.HttpError(http.StatusServiceUnavailable, "OAuth requires session cookies to be enabled")
|
||||||
|
}
|
||||||
|
callbackURL, err := api.oauth.callbackURL(r)
|
||||||
|
if err != nil {
|
||||||
|
return utils.HttpInternalServerError("unable to determine OAuth callback URL").WithInternalErr(err)
|
||||||
|
}
|
||||||
|
location, err := api.oauth.service.Start(r.Context(), callbackURL)
|
||||||
|
if err != nil {
|
||||||
|
return oauthServiceError(err, true)
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, location, http.StatusFound)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveConfig uses OpenID Connect discovery when an issuer is configured.
|
func (api *ApiManagerCtx) OAuthCallback(w http.ResponseWriter, r *http.Request) error {
|
||||||
// Issuer metadata deliberately takes precedence over explicitly configured endpoints.
|
if !api.oauth.providerOAuth || !api.oauth.service.Enabled() {
|
||||||
func (handler *oauthHandler) resolveConfig(ctx context.Context) (config.OAuth, error) {
|
return utils.HttpNotFound()
|
||||||
handler.configMu.Lock()
|
|
||||||
defer handler.configMu.Unlock()
|
|
||||||
|
|
||||||
if handler.config.IssuerURL == "" || handler.issuerResolved {
|
|
||||||
return handler.config, nil
|
|
||||||
}
|
}
|
||||||
|
if providerError := r.URL.Query().Get("error"); providerError != "" {
|
||||||
discoveryURL, err := openIDConfigurationURL(handler.config.IssuerURL)
|
return utils.HttpUnauthorized("OAuth authorization was declined").WithInternalMsg(providerError)
|
||||||
|
}
|
||||||
|
_, token, err := api.oauth.service.Complete(r.Context(), r.URL.Query().Get("state"), r.URL.Query().Get("code"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return config.OAuth{}, err
|
return oauthServiceError(err, false)
|
||||||
}
|
}
|
||||||
|
api.sessions.CookieSetToken(w, token)
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, discoveryURL, nil)
|
http.Redirect(w, r, api.oauth.successRedirect(), http.StatusSeeOther)
|
||||||
if err != nil {
|
return 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) {
|
func oauthServiceError(err error, start bool) error {
|
||||||
issuer, err := url.Parse(issuerURL)
|
if errors.Is(err, types.ErrSessionAlreadyConnected) {
|
||||||
if err != nil || !isAbsoluteHTTPURL(issuerURL) {
|
return utils.HttpUnprocessableEntity("session already connected")
|
||||||
return "", errors.New("OAuth issuer URL must be an absolute HTTP(S) URL")
|
|
||||||
}
|
}
|
||||||
issuer.Path = strings.TrimSuffix(issuer.Path, "/") + "/.well-known/openid-configuration"
|
if errors.Is(err, types.ErrSessionLoginsLocked) {
|
||||||
issuer.RawQuery = ""
|
return utils.HttpForbidden("logins are locked").WithInternalErr(err)
|
||||||
issuer.Fragment = ""
|
}
|
||||||
return issuer.String(), nil
|
if start && strings.HasPrefix(err.Error(), "issuer discovery:") {
|
||||||
}
|
return utils.HttpError(http.StatusServiceUnavailable, "OAuth issuer discovery failed").WithInternalErr(err)
|
||||||
|
}
|
||||||
func isAbsoluteHTTPURL(value string) bool {
|
if start {
|
||||||
endpoint, err := url.Parse(value)
|
return utils.HttpError(http.StatusServiceUnavailable, err.Error()).WithInternalErr(err)
|
||||||
return err == nil && endpoint.Host != "" && (endpoint.Scheme == "http" || endpoint.Scheme == "https")
|
}
|
||||||
|
if strings.Contains(err.Error(), "missing state or code") || strings.Contains(err.Error(), "state is invalid") {
|
||||||
|
return utils.HttpBadRequest(err.Error()).WithInternalErr(err)
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(err.Error(), "issuer discovery:") || strings.Contains(err.Error(), "not fully configured") {
|
||||||
|
return utils.HttpError(http.StatusServiceUnavailable, "OAuth issuer discovery failed").WithInternalErr(err)
|
||||||
|
}
|
||||||
|
return utils.HttpUnauthorized("OAuth authorization failed").WithInternalErr(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (handler *oauthHandler) callbackURL(r *http.Request) (string, error) {
|
func (handler *oauthHandler) callbackURL(r *http.Request) (string, error) {
|
||||||
@@ -170,320 +108,29 @@ func (handler *oauthHandler) callbackURL(r *http.Request) (string, error) {
|
|||||||
}
|
}
|
||||||
host := r.Host
|
host := r.Host
|
||||||
if handler.trustProxy {
|
if handler.trustProxy {
|
||||||
if forwardedProto := forwardedHeaderValue(r.Header.Get("X-Forwarded-Proto")); forwardedProto != "" {
|
if value := forwardedHeaderValue(r.Header.Get("X-Forwarded-Proto")); value != "" {
|
||||||
scheme = forwardedProto
|
scheme = value
|
||||||
}
|
}
|
||||||
if forwardedHost := forwardedHeaderValue(r.Header.Get("X-Forwarded-Host")); forwardedHost != "" {
|
if value := forwardedHeaderValue(r.Header.Get("X-Forwarded-Host")); value != "" {
|
||||||
host = forwardedHost
|
host = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if host == "" || (scheme != "http" && scheme != "https") {
|
if host == "" || (scheme != "http" && scheme != "https") {
|
||||||
return "", errors.New("request has no valid public URL")
|
return "", errors.New("request has no valid public URL")
|
||||||
}
|
}
|
||||||
|
callback := (&url.URL{Scheme: scheme, Host: host, Path: path.Join(handler.pathPrefix, "/api/oauth/callback")}).String()
|
||||||
callback := &url.URL{
|
if parsed, err := url.Parse(callback); err != nil || parsed.Host == "" {
|
||||||
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 "", errors.New("request has no valid public URL")
|
||||||
}
|
}
|
||||||
return callback.String(), nil
|
return callback, 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 {
|
func (handler *oauthHandler) successRedirect() string {
|
||||||
redirect := handler.config.SuccessRedirect
|
redirect := handler.service.SuccessRedirect()
|
||||||
if strings.HasPrefix(redirect, "/") && !strings.HasPrefix(redirect, "//") {
|
if strings.HasPrefix(redirect, "/") && !strings.HasPrefix(redirect, "//") {
|
||||||
return path.Join(handler.pathPrefix, redirect)
|
return path.Join(handler.pathPrefix, redirect)
|
||||||
}
|
}
|
||||||
return handler.pathPrefix
|
return handler.pathPrefix
|
||||||
}
|
}
|
||||||
|
func forwardedHeaderValue(value string) string {
|
||||||
func newOAuthToken(bytes int) (string, error) {
|
return strings.TrimSpace(strings.Split(value, ",")[0])
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"github.com/m1k1o/neko/server/internal/config"
|
"github.com/m1k1o/neko/server/internal/config"
|
||||||
"github.com/m1k1o/neko/server/internal/member"
|
"github.com/m1k1o/neko/server/internal/member"
|
||||||
|
"github.com/m1k1o/neko/server/internal/member/oauth"
|
||||||
"github.com/m1k1o/neko/server/internal/session"
|
"github.com/m1k1o/neko/server/internal/session"
|
||||||
"github.com/m1k1o/neko/server/pkg/types"
|
"github.com/m1k1o/neko/server/pkg/types"
|
||||||
)
|
)
|
||||||
@@ -49,7 +50,7 @@ func TestOAuthLoginSynchronizesProfile(t *testing.T) {
|
|||||||
|
|
||||||
memberConfig := &config.Member{
|
memberConfig := &config.Member{
|
||||||
Provider: "oauth",
|
Provider: "oauth",
|
||||||
OAuth: config.OAuth{
|
OAuth: oauth.Config{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
ClientID: "client-id",
|
ClientID: "client-id",
|
||||||
ClientSecret: "client-secret",
|
ClientSecret: "client-secret",
|
||||||
@@ -122,7 +123,7 @@ func TestOAuthLoginSynchronizesProfile(t *testing.T) {
|
|||||||
if profile.Name != "Ada Lovelace" || profile.Avatar != "https://example.test/ada.png" || !profile.IsAdmin || profile.CanAccessClipboard {
|
if profile.Name != "Ada Lovelace" || profile.Avatar != "https://example.test/ada.png" || !profile.IsAdmin || profile.CanAccessClipboard {
|
||||||
t.Fatalf("profile = %#v", profile)
|
t.Fatalf("profile = %#v", profile)
|
||||||
}
|
}
|
||||||
if extraData := members.OAuthExtraData(userSession.ID()); extraData["displayName"] != "Ada Lovelace" || extraData["isAdmin"] != "true" {
|
if extraData := api.oauth.service.ExtraData(userSession.ID()); extraData["displayName"] != "Ada Lovelace" || extraData["isAdmin"] != "true" {
|
||||||
t.Fatalf("extra data = %#v", extraData)
|
t.Fatalf("extra data = %#v", extraData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +167,7 @@ func TestOAuthIssuerDiscoveryTakesPrecedence(t *testing.T) {
|
|||||||
issuerURL = issuer.URL
|
issuerURL = issuer.URL
|
||||||
memberConfig := &config.Member{
|
memberConfig := &config.Member{
|
||||||
Provider: "oauth",
|
Provider: "oauth",
|
||||||
OAuth: config.OAuth{
|
OAuth: oauth.Config{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
ClientID: "client-id",
|
ClientID: "client-id",
|
||||||
ClientSecret: "client-secret",
|
ClientSecret: "client-secret",
|
||||||
@@ -224,7 +225,7 @@ func TestOAuthIssuerDiscoveryTakesPrecedence(t *testing.T) {
|
|||||||
func TestOAuthConfigUsesProviderAndName(t *testing.T) {
|
func TestOAuthConfigUsesProviderAndName(t *testing.T) {
|
||||||
memberConfig := &config.Member{
|
memberConfig := &config.Member{
|
||||||
Provider: "oauth",
|
Provider: "oauth",
|
||||||
OAuth: config.OAuth{
|
OAuth: oauth.Config{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
Name: "Team SSO",
|
Name: "Team SSO",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"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/internal/config"
|
||||||
|
"github.com/m1k1o/neko/server/internal/member/oauth"
|
||||||
"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"
|
||||||
@@ -42,7 +43,7 @@ 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"),
|
oauth: newOAuthHandler(oauth.NewService(memberConfig.OAuth, sessions), pathPrefix, serverConfig != nil && serverConfig.Proxy, memberConfig.Provider == "oauth"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ func (api *ApiManagerCtx) Logout(w http.ResponseWriter, r *http.Request) error {
|
|||||||
return utils.HttpInternalServerError().WithInternalErr(err)
|
return utils.HttpInternalServerError().WithInternalErr(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if api.sessions.CookieEnabled() {
|
if api.sessions.CookieEnabled() {
|
||||||
api.sessions.CookieClearToken(w, r)
|
api.sessions.CookieClearToken(w, r)
|
||||||
}
|
}
|
||||||
@@ -82,7 +81,7 @@ func (api *ApiManagerCtx) Whoami(w http.ResponseWriter, r *http.Request) error {
|
|||||||
ID: session.ID(),
|
ID: session.ID(),
|
||||||
Profile: session.Profile(),
|
Profile: session.Profile(),
|
||||||
State: session.State(),
|
State: session.State(),
|
||||||
ExtraData: api.members.OAuthExtraData(session.ID()),
|
ExtraData: api.oauth.service.ExtraData(session.ID()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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/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"
|
||||||
"github.com/m1k1o/neko/server/pkg/utils"
|
"github.com/m1k1o/neko/server/pkg/utils"
|
||||||
@@ -19,30 +20,7 @@ type Member struct {
|
|||||||
File file.Config
|
File file.Config
|
||||||
Object object.Config
|
Object object.Config
|
||||||
Multiuser multiuser.Config
|
Multiuser multiuser.Config
|
||||||
OAuth OAuth
|
OAuth oauth.Config
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
AdminProfile types.MemberProfile
|
|
||||||
UserProfile types.MemberProfile
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Member) Init(cmd *cobra.Command) error {
|
func (Member) Init(cmd *cobra.Command) error {
|
||||||
@@ -89,94 +67,7 @@ func (Member) Init(cmd *cobra.Command) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuth 2.0 provider
|
if err := oauth.Init(cmd); err != nil {
|
||||||
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().StringSlice("member.oauth.admin_emails", []string{}, "OAuth user-info email addresses granted administrator access")
|
|
||||||
if err := viper.BindPFlag("member.oauth.admin_emails", cmd.PersistentFlags().Lookup("member.oauth.admin_emails")); 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
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd.PersistentFlags().String("member.oauth.admin_profile", "{}", "OAuth administrator permission profile")
|
|
||||||
if err := viper.BindPFlag("member.oauth.admin_profile", cmd.PersistentFlags().Lookup("member.oauth.admin_profile")); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,24 +106,6 @@ 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 = viper.GetStringSlice("member.oauth.admin_emails")
|
|
||||||
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,
|
||||||
@@ -245,7 +118,6 @@ 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(
|
||||||
@@ -253,11 +125,6 @@ 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{
|
||||||
@@ -278,11 +145,8 @@ func (s *Member) Set() {
|
|||||||
)); err != nil {
|
)); err != nil {
|
||||||
log.Warn().Err(err).Msgf("unable to parse member multiuser admin profile")
|
log.Warn().Err(err).Msgf("unable to parse member multiuser admin profile")
|
||||||
}
|
}
|
||||||
s.OAuth.AdminProfile = s.Multiuser.AdminProfile
|
if err := s.OAuth.Set(s.Multiuser.UserProfile, s.Multiuser.AdminProfile); err != nil {
|
||||||
if err := viper.UnmarshalKey("member.oauth.admin_profile", &s.OAuth.AdminProfile, viper.DecodeHook(
|
log.Warn().Err(err).Msg("unable to configure member OAuth provider")
|
||||||
utils.JsonStringAutoDecode(s.OAuth.AdminProfile),
|
|
||||||
)); err != nil {
|
|
||||||
log.Warn().Err(err).Msgf("unable to parse member OAuth admin profile")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/m1k1o/neko/server/internal/api"
|
"github.com/m1k1o/neko/server/internal/api"
|
||||||
"github.com/m1k1o/neko/server/internal/config"
|
"github.com/m1k1o/neko/server/internal/config"
|
||||||
"github.com/m1k1o/neko/server/internal/member"
|
"github.com/m1k1o/neko/server/internal/member"
|
||||||
|
"github.com/m1k1o/neko/server/internal/member/oauth"
|
||||||
"github.com/m1k1o/neko/server/internal/session"
|
"github.com/m1k1o/neko/server/internal/session"
|
||||||
"github.com/m1k1o/neko/server/pkg/types"
|
"github.com/m1k1o/neko/server/pkg/types"
|
||||||
)
|
)
|
||||||
@@ -35,7 +36,7 @@ func TestOAuthAutoRedirectSkipsAuthenticatedSessions(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
memberConfig := &config.Member{Provider: "oauth", OAuth: config.OAuth{Enabled: true, AutoRedirect: true}}
|
memberConfig := &config.Member{Provider: "oauth", OAuth: oauth.Config{Enabled: true, AutoRedirect: true}}
|
||||||
sessionManager := session.New(&config.Session{
|
sessionManager := session.New(&config.Session{
|
||||||
Cookie: config.SessionCookie{Enabled: true, Name: "NEKO_SESSION", Expiration: time.Hour},
|
Cookie: config.SessionCookie{Enabled: true, Name: "NEKO_SESSION", Expiration: time.Hour},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package member
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
@@ -12,17 +11,16 @@ 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/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 {
|
||||||
@@ -33,7 +31,7 @@ func New(sessions types.SessionManager, config *config.Member) *MemberManagerCtx
|
|||||||
case "multiuser":
|
case "multiuser":
|
||||||
manager.provider = multiuser.New(config.Multiuser)
|
manager.provider = multiuser.New(config.Multiuser)
|
||||||
case "oauth":
|
case "oauth":
|
||||||
manager.provider = oauthprovider.New()
|
manager.provider = oauth.New()
|
||||||
case "noauth":
|
case "noauth":
|
||||||
fallthrough
|
fallthrough
|
||||||
default:
|
default:
|
||||||
@@ -50,7 +48,6 @@ 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 {
|
||||||
@@ -124,11 +121,6 @@ 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)
|
||||||
@@ -171,88 +163,10 @@ 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 isOAuthAdministrator(email, isAdmin, manager.config.OAuth.AdminEmails) {
|
|
||||||
profile = manager.config.OAuth.AdminProfile
|
|
||||||
}
|
|
||||||
if name != "" {
|
|
||||||
profile.Name = name
|
|
||||||
}
|
|
||||||
profile.Avatar = avatar
|
|
||||||
|
|
||||||
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()
|
||||||
|
|
||||||
err := manager.sessions.Delete(id)
|
err := manager.sessions.Delete(id)
|
||||||
if err == nil || errors.Is(err, types.ErrSessionNotFound) {
|
|
||||||
delete(manager.oauthExtra, id)
|
|
||||||
}
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package oauth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
|
||||||
|
"github.com/m1k1o/neko/server/pkg/types"
|
||||||
|
"github.com/m1k1o/neko/server/pkg/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config contains the OAuth member provider settings.
|
||||||
|
type Config 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
|
||||||
|
AdminProfile types.MemberProfile
|
||||||
|
UserProfile types.MemberProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
func Init(cmd *cobra.Command) error {
|
||||||
|
flags := cmd.PersistentFlags()
|
||||||
|
flags.Bool("member.oauth.enabled", false, "enable OAuth 2.0 login")
|
||||||
|
if err := viper.BindPFlag("member.oauth.enabled", flags.Lookup("member.oauth.enabled")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.Bool("member.oauth.auto_redirect", false, "redirect the Neko root page to OAuth login automatically")
|
||||||
|
if err := viper.BindPFlag("member.oauth.auto_redirect", flags.Lookup("member.oauth.auto_redirect")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.String("member.oauth.name", "OAuth", "display name for the OAuth login option")
|
||||||
|
if err := viper.BindPFlag("member.oauth.name", flags.Lookup("member.oauth.name")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.StringSlice("member.oauth.admin_emails", []string{}, "OAuth user-info email addresses granted administrator access")
|
||||||
|
if err := viper.BindPFlag("member.oauth.admin_emails", flags.Lookup("member.oauth.admin_emails")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.String("member.oauth.client_id", "", "OAuth 2.0 client ID")
|
||||||
|
if err := viper.BindPFlag("member.oauth.client_id", flags.Lookup("member.oauth.client_id")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.String("member.oauth.client_secret", "", "OAuth 2.0 client secret")
|
||||||
|
if err := viper.BindPFlag("member.oauth.client_secret", flags.Lookup("member.oauth.client_secret")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.String("member.oauth.issuer_url", "", "OpenID Connect issuer URL used to discover OAuth endpoints")
|
||||||
|
if err := viper.BindPFlag("member.oauth.issuer_url", flags.Lookup("member.oauth.issuer_url")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.String("member.oauth.authorization_url", "", "OAuth 2.0 authorization endpoint")
|
||||||
|
if err := viper.BindPFlag("member.oauth.authorization_url", flags.Lookup("member.oauth.authorization_url")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.String("member.oauth.token_url", "", "OAuth 2.0 token endpoint")
|
||||||
|
if err := viper.BindPFlag("member.oauth.token_url", flags.Lookup("member.oauth.token_url")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.String("member.oauth.userinfo_url", "", "OAuth 2.0 user-info endpoint")
|
||||||
|
if err := viper.BindPFlag("member.oauth.userinfo_url", flags.Lookup("member.oauth.userinfo_url")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.String("member.oauth.redirect_url", "", "OAuth 2.0 callback URL registered with the provider")
|
||||||
|
if err := viper.BindPFlag("member.oauth.redirect_url", flags.Lookup("member.oauth.redirect_url")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.StringSlice("member.oauth.scopes", []string{"openid", "profile"}, "OAuth 2.0 scopes")
|
||||||
|
if err := viper.BindPFlag("member.oauth.scopes", flags.Lookup("member.oauth.scopes")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.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", flags.Lookup("member.oauth.subject_field")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.String("member.oauth.username_field", "name", "field in user-info response used as the display name")
|
||||||
|
if err := viper.BindPFlag("member.oauth.username_field", flags.Lookup("member.oauth.username_field")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.String("member.oauth.avatar_field", "picture", "field in user-info response used as the avatar URL")
|
||||||
|
if err := viper.BindPFlag("member.oauth.avatar_field", flags.Lookup("member.oauth.avatar_field")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.String("member.oauth.success_redirect", "/", "relative URL to redirect to after OAuth login")
|
||||||
|
if err := viper.BindPFlag("member.oauth.success_redirect", flags.Lookup("member.oauth.success_redirect")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.String("member.oauth.user_profile", "{}", "OAuth user permission profile")
|
||||||
|
if err := viper.BindPFlag("member.oauth.user_profile", flags.Lookup("member.oauth.user_profile")); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flags.String("member.oauth.admin_profile", "{}", "OAuth administrator permission profile")
|
||||||
|
return viper.BindPFlag("member.oauth.admin_profile", flags.Lookup("member.oauth.admin_profile"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Config) Set(userProfile, adminProfile types.MemberProfile) error {
|
||||||
|
c.Enabled = viper.GetBool("member.oauth.enabled")
|
||||||
|
c.AutoRedirect = viper.GetBool("member.oauth.auto_redirect")
|
||||||
|
c.Name = viper.GetString("member.oauth.name")
|
||||||
|
c.AdminEmails = viper.GetStringSlice("member.oauth.admin_emails")
|
||||||
|
c.ClientID = viper.GetString("member.oauth.client_id")
|
||||||
|
c.ClientSecret = viper.GetString("member.oauth.client_secret")
|
||||||
|
c.IssuerURL = viper.GetString("member.oauth.issuer_url")
|
||||||
|
c.AuthorizationURL = viper.GetString("member.oauth.authorization_url")
|
||||||
|
c.TokenURL = viper.GetString("member.oauth.token_url")
|
||||||
|
c.UserInfoURL = viper.GetString("member.oauth.userinfo_url")
|
||||||
|
c.RedirectURL = viper.GetString("member.oauth.redirect_url")
|
||||||
|
c.Scopes = viper.GetStringSlice("member.oauth.scopes")
|
||||||
|
c.SubjectField = viper.GetString("member.oauth.subject_field")
|
||||||
|
c.UsernameField = viper.GetString("member.oauth.username_field")
|
||||||
|
c.AvatarField = viper.GetString("member.oauth.avatar_field")
|
||||||
|
c.SuccessRedirect = viper.GetString("member.oauth.success_redirect")
|
||||||
|
c.UserProfile = userProfile
|
||||||
|
c.AdminProfile = adminProfile
|
||||||
|
if err := viper.UnmarshalKey("member.oauth.user_profile", &c.UserProfile, viper.DecodeHook(utils.JsonStringAutoDecode(c.UserProfile))); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return viper.UnmarshalKey("member.oauth.admin_profile", &c.AdminProfile, viper.DecodeHook(utils.JsonStringAutoDecode(c.AdminProfile)))
|
||||||
|
}
|
||||||
@@ -1,13 +1,25 @@
|
|||||||
package oauth
|
package oauth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/m1k1o/neko/server/pkg/types"
|
"github.com/m1k1o/neko/server/pkg/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MemberProviderCtx disables password authentication. OAuth sessions are
|
// MemberProviderCtx disables password authentication. OAuth sessions are
|
||||||
// created by MemberManagerCtx.LoginOAuth after the OAuth callback succeeds.
|
// created by Service after the OAuth callback succeeds.
|
||||||
type MemberProviderCtx struct{}
|
type MemberProviderCtx struct{}
|
||||||
|
|
||||||
func New() types.MemberProvider { return &MemberProviderCtx{} }
|
func New() types.MemberProvider { return &MemberProviderCtx{} }
|
||||||
@@ -43,3 +55,374 @@ func (provider *MemberProviderCtx) SelectAll(limit int, offset int) (map[string]
|
|||||||
func (provider *MemberProviderCtx) Delete(id string) error {
|
func (provider *MemberProviderCtx) Delete(id string) error {
|
||||||
return errors.New("OAuth members are managed by the identity provider")
|
return errors.New("OAuth members are managed by the identity provider")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stateLifetime = 10 * time.Minute
|
||||||
|
|
||||||
|
type state struct {
|
||||||
|
verifier, redirectURL string
|
||||||
|
expires time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service owns the OAuth authorization-code flow and creates sessions for the
|
||||||
|
// OAuth member provider. HTTP packages only translate requests and responses.
|
||||||
|
type Service struct {
|
||||||
|
config Config
|
||||||
|
sessions types.SessionManager
|
||||||
|
client *http.Client
|
||||||
|
mu sync.Mutex
|
||||||
|
issuerResolved bool
|
||||||
|
states map[string]state
|
||||||
|
extra map[string]map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(config Config, sessions types.SessionManager) *Service {
|
||||||
|
service := &Service{
|
||||||
|
config: config,
|
||||||
|
sessions: sessions,
|
||||||
|
client: &http.Client{Timeout: 15 * time.Second},
|
||||||
|
states: make(map[string]state),
|
||||||
|
extra: make(map[string]map[string]any),
|
||||||
|
}
|
||||||
|
sessions.OnDeleted(func(session types.Session) {
|
||||||
|
service.DeleteExtraData(session.ID())
|
||||||
|
})
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) Enabled() bool { return service.config.Enabled }
|
||||||
|
func (service *Service) Name() string {
|
||||||
|
if name := strings.TrimSpace(service.config.Name); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return "OAuth"
|
||||||
|
}
|
||||||
|
func (service *Service) SuccessRedirect() string { return service.config.SuccessRedirect }
|
||||||
|
|
||||||
|
func (service *Service) Start(ctx context.Context, callbackURL string) (string, error) {
|
||||||
|
config, err := service.resolveConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("issuer discovery: %w", err)
|
||||||
|
}
|
||||||
|
if !configured(config) {
|
||||||
|
return "", errors.New("OAuth is not fully configured")
|
||||||
|
}
|
||||||
|
if config.RedirectURL != "" {
|
||||||
|
callbackURL = config.RedirectURL
|
||||||
|
}
|
||||||
|
if callbackURL == "" {
|
||||||
|
return "", errors.New("OAuth callback URL is not configured")
|
||||||
|
}
|
||||||
|
stateToken, err := token(32)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
verifier, err := token(64)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
service.storeState(stateToken, verifier, callbackURL)
|
||||||
|
authorizationURL, err := url.Parse(config.AuthorizationURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
query := authorizationURL.Query()
|
||||||
|
query.Set("response_type", "code")
|
||||||
|
query.Set("client_id", config.ClientID)
|
||||||
|
query.Set("redirect_uri", callbackURL)
|
||||||
|
query.Set("scope", strings.Join(config.Scopes, " "))
|
||||||
|
query.Set("state", stateToken)
|
||||||
|
query.Set("code_challenge", challenge(verifier))
|
||||||
|
query.Set("code_challenge_method", "S256")
|
||||||
|
authorizationURL.RawQuery = query.Encode()
|
||||||
|
return authorizationURL.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) Complete(ctx context.Context, stateToken, code string) (types.Session, string, error) {
|
||||||
|
if !service.config.Enabled {
|
||||||
|
return nil, "", types.ErrMemberInvalidPassword
|
||||||
|
}
|
||||||
|
if stateToken == "" || code == "" {
|
||||||
|
return nil, "", errors.New("OAuth callback is missing state or code")
|
||||||
|
}
|
||||||
|
stored, ok := service.popState(stateToken)
|
||||||
|
if !ok {
|
||||||
|
return nil, "", errors.New("OAuth state is invalid or expired")
|
||||||
|
}
|
||||||
|
config, err := service.resolveConfig(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("issuer discovery: %w", err)
|
||||||
|
}
|
||||||
|
if !configured(config) {
|
||||||
|
return nil, "", errors.New("OAuth is not fully configured")
|
||||||
|
}
|
||||||
|
accessToken, idClaims, err := service.exchangeCode(ctx, config, code, stored.verifier, stored.redirectURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("token exchange: %w", err)
|
||||||
|
}
|
||||||
|
claims, err := service.userInfo(ctx, config, accessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("user-info: %w", err)
|
||||||
|
}
|
||||||
|
subject := claim(claims, config.SubjectField)
|
||||||
|
if subject == "" {
|
||||||
|
return nil, "", errors.New("OAuth user-info response is missing the subject field")
|
||||||
|
}
|
||||||
|
if idSubject := claim(idClaims, config.SubjectField); idSubject != "" && idSubject != subject {
|
||||||
|
return nil, "", errors.New("OAuth ID token subject does not match user-info response")
|
||||||
|
}
|
||||||
|
name := claim(claims, config.UsernameField)
|
||||||
|
if name == "" {
|
||||||
|
name = claim(idClaims, config.UsernameField)
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = subject
|
||||||
|
}
|
||||||
|
avatar := claim(claims, config.AvatarField)
|
||||||
|
if avatar == "" {
|
||||||
|
avatar = claim(idClaims, config.AvatarField)
|
||||||
|
}
|
||||||
|
email := claim(claims, "email")
|
||||||
|
if email == "" {
|
||||||
|
email = claim(idClaims, "email")
|
||||||
|
}
|
||||||
|
profile := config.UserProfile
|
||||||
|
if administrator(email, boolClaim(claims, "isAdmin") || boolClaim(idClaims, "isAdmin"), config.AdminEmails) {
|
||||||
|
profile = config.AdminProfile
|
||||||
|
}
|
||||||
|
profile.Name = name
|
||||||
|
profile.Avatar = avatar
|
||||||
|
if !profile.IsAdmin && service.sessions.Settings().LockedLogins {
|
||||||
|
return nil, "", types.ErrSessionLoginsLocked
|
||||||
|
}
|
||||||
|
id := "oauth:" + subject
|
||||||
|
if session, ok := service.sessions.Get(id); ok {
|
||||||
|
if session.State().IsConnected {
|
||||||
|
return nil, "", types.ErrSessionAlreadyConnected
|
||||||
|
}
|
||||||
|
if err := service.sessions.Delete(id); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
session, sessionToken, err := service.sessions.Create(id, profile)
|
||||||
|
if err == nil {
|
||||||
|
service.mu.Lock()
|
||||||
|
service.extra[id] = mergeClaims(claims, idClaims)
|
||||||
|
service.mu.Unlock()
|
||||||
|
}
|
||||||
|
return session, sessionToken, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) ExtraData(id string) map[string]any {
|
||||||
|
service.mu.Lock()
|
||||||
|
defer service.mu.Unlock()
|
||||||
|
return cloneClaims(service.extra[id])
|
||||||
|
}
|
||||||
|
func (service *Service) DeleteExtraData(id string) {
|
||||||
|
service.mu.Lock()
|
||||||
|
defer service.mu.Unlock()
|
||||||
|
delete(service.extra, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
type discovery struct {
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||||
|
TokenEndpoint string `json:"token_endpoint"`
|
||||||
|
UserInfoEndpoint string `json:"userinfo_endpoint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (service *Service) resolveConfig(ctx context.Context) (Config, error) {
|
||||||
|
service.mu.Lock()
|
||||||
|
defer service.mu.Unlock()
|
||||||
|
if service.config.IssuerURL == "" || service.issuerResolved {
|
||||||
|
return service.config, nil
|
||||||
|
}
|
||||||
|
issuer, err := url.Parse(service.config.IssuerURL)
|
||||||
|
if err != nil || !absoluteURL(service.config.IssuerURL) {
|
||||||
|
return Config{}, 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 = ""
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, issuer.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
response, err := service.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
return Config{}, fmt.Errorf("issuer discovery endpoint returned %s", response.Status)
|
||||||
|
}
|
||||||
|
var metadata discovery
|
||||||
|
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&metadata); err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
if metadata.Issuer != service.config.IssuerURL {
|
||||||
|
return Config{}, errors.New("issuer discovery response issuer does not match configured issuer")
|
||||||
|
}
|
||||||
|
for field, value := range map[string]string{"authorization_endpoint": metadata.AuthorizationEndpoint, "token_endpoint": metadata.TokenEndpoint, "userinfo_endpoint": metadata.UserInfoEndpoint} {
|
||||||
|
if !absoluteURL(value) {
|
||||||
|
return Config{}, fmt.Errorf("issuer discovery response has invalid %s", field)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service.config.AuthorizationURL = metadata.AuthorizationEndpoint
|
||||||
|
service.config.TokenURL = metadata.TokenEndpoint
|
||||||
|
service.config.UserInfoURL = metadata.UserInfoEndpoint
|
||||||
|
service.issuerResolved = true
|
||||||
|
return service.config, nil
|
||||||
|
}
|
||||||
|
func configured(config Config) bool {
|
||||||
|
return config.Enabled && config.ClientID != "" && config.ClientSecret != "" && config.AuthorizationURL != "" && config.TokenURL != "" && config.UserInfoURL != ""
|
||||||
|
}
|
||||||
|
func absoluteURL(value string) bool {
|
||||||
|
parsed, err := url.Parse(value)
|
||||||
|
return err == nil && parsed.Host != "" && (parsed.Scheme == "http" || parsed.Scheme == "https")
|
||||||
|
}
|
||||||
|
func (service *Service) storeState(value, verifier, redirectURL string) {
|
||||||
|
service.mu.Lock()
|
||||||
|
defer service.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
for key, value := range service.states {
|
||||||
|
if now.After(value.expires) {
|
||||||
|
delete(service.states, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service.states[value] = state{verifier, redirectURL, now.Add(stateLifetime)}
|
||||||
|
}
|
||||||
|
func (service *Service) popState(value string) (state, bool) {
|
||||||
|
service.mu.Lock()
|
||||||
|
defer service.mu.Unlock()
|
||||||
|
result, ok := service.states[value]
|
||||||
|
delete(service.states, value)
|
||||||
|
return result, ok && time.Now().Before(result.expires)
|
||||||
|
}
|
||||||
|
func (service *Service) exchangeCode(ctx context.Context, config Config, code, verifier, redirectURL string) (string, map[string]any, 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(ctx, http.MethodPost, config.TokenURL, strings.NewReader(values.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
response, err := service.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
return "", nil, 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 "", nil, err
|
||||||
|
}
|
||||||
|
if payload.AccessToken == "" {
|
||||||
|
return "", nil, errors.New("token response has no access_token")
|
||||||
|
}
|
||||||
|
return payload.AccessToken, idTokenClaims(payload.IDToken), nil
|
||||||
|
}
|
||||||
|
func (service *Service) userInfo(ctx context.Context, config Config, accessToken string) (map[string]any, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, 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 := service.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||||
|
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 token(bytes int) (string, error) {
|
||||||
|
buffer := make([]byte, bytes)
|
||||||
|
if _, err := rand.Read(buffer); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buffer), nil
|
||||||
|
}
|
||||||
|
func challenge(verifier string) string {
|
||||||
|
digest := sha256.Sum256([]byte(verifier))
|
||||||
|
return base64.RawURLEncoding.EncodeToString(digest[:])
|
||||||
|
}
|
||||||
|
func idTokenClaims(value string) map[string]any {
|
||||||
|
parts := strings.Split(value, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
claims := map[string]any{}
|
||||||
|
if json.Unmarshal(payload, &claims) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
func claim(claims map[string]any, field string) string {
|
||||||
|
if value, ok := claims[field]; ok {
|
||||||
|
return fmt.Sprint(value)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func boolClaim(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
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
func administrator(email string, isAdmin bool, emails []string) bool {
|
||||||
|
if isAdmin {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, candidate := range emails {
|
||||||
|
if email != "" && strings.EqualFold(strings.TrimSpace(email), strings.TrimSpace(candidate)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
func mergeClaims(userInfo, idToken map[string]any) map[string]any {
|
||||||
|
result := make(map[string]any, len(userInfo)+len(idToken))
|
||||||
|
for key, value := range idToken {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
for key, value := range userInfo {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
func cloneClaims(claims map[string]any) map[string]any {
|
||||||
|
if len(claims) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make(map[string]any, len(claims))
|
||||||
|
for key, value := range claims {
|
||||||
|
result[key] = value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,7 +45,5 @@ 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