More strict ext-auth protocols, remove jwt cookie + small fixes

This commit is contained in:
Denis Arh
2019-04-18 19:53:02 +02:00
parent 316651c99a
commit c4104488e5
17 changed files with 250 additions and 60 deletions
+16
View File
@@ -19,6 +19,22 @@
"path": "/check",
"parameters": {}
},
{
"name": "exchangeAuthToken",
"method": "POST",
"title": "Exchange auth token for JWT",
"path": "/exchange",
"parameters": {
"post": [
{
"name": "token",
"type": "string",
"required": true,
"title": "Token to be exchanged for JWT"
}
]
}
},
{
"name": "logout",
"method": "GET",
+16
View File
@@ -21,6 +21,22 @@
"Path": "/check",
"Parameters": {}
},
{
"Name": "exchangeAuthToken",
"Method": "POST",
"Title": "Exchange auth token for JWT",
"Path": "/exchange",
"Parameters": {
"post": [
{
"name": "token",
"required": true,
"title": "Token to be exchanged for JWT",
"type": "string"
}
]
}
},
{
"Name": "logout",
"Method": "GET",
+7 -7
View File
@@ -31,10 +31,10 @@ auth.external.providers.github.secret null
auth.external.providers.linkedin.enabled false
auth.external.providers.linkedin.key null
auth.external.providers.linkedin.secret null
auth.external.providers.openid-connect.didmos2.enabled true
auth.external.providers.openid-connect.didmos2.key "tXM2ouiovowzGabk"
auth.external.providers.openid-connect.didmos2.issuer "https://satosa.didmos.latest.crust.tech"
auth.external.providers.openid-connect.didmos2.secret "e1d68bfd7718468ba8fd36131f5176b1"
auth.external.providers.openid-connect.crust-iam.enabled true
auth.external.providers.openid-connect.crust-iam.key "tXM2ouiovowzGabk"
auth.external.providers.openid-connect.crust-iam.issuer "https://satosa.didmos.latest.crust.tech"
auth.external.providers.openid-connect.crust-iam.secret "e1d68bfd7718468ba8fd36131f5176b1"
auth.external.redirect-url "http://system.api.local.crust.tech:3002/auth/external/%s/callback"
auth.external.session-store-secret "fCVFSRWjVEcoYuhXSf3f6zVWO1p38XEWz2yS8WH7wKDbvpxFrZq7zlEuiUTvk4QF"
```
@@ -52,7 +52,7 @@ On startup, you should see log entries similar to these:
initializing external authentication providers (3)
external authentication provider "facebook" added
external authentication provider "gplus" added
external authentication provider "openid-connect.didmos2" added
external authentication provider "openid-connect.crust-iam" added
```
@@ -64,7 +64,7 @@ system-cli external-auth auto-discovery name url
```
```bash
system-cli external-auth auto-discovery didmos2 https://satosa.didmos.crust.example.tld
system-cli external-auth auto-discovery crust-iam https://satosa.didmos.crust.example.tld
```
This will autodiscover and autoconfigure new OIDC provider.
@@ -74,5 +74,5 @@ Please note that this provider is disabled by default.
To enable it, run:
```bash
system-cli settings key auth.external.providers.openid-connect.didmos2.enabled true
system-cli settings key auth.external.providers.openid-connect.crust-iam.enabled true
```
+15
View File
@@ -95,6 +95,7 @@
| ------ | -------- | ------- |
| `GET` | `/auth/` | Returns auth settings |
| `GET` | `/auth/check` | Check JWT token |
| `POST` | `/auth/exchange` | Exchange auth token for JWT |
| `GET` | `/auth/logout` | Logout |
## Returns auth settings
@@ -123,6 +124,20 @@
| Parameter | Type | Method | Description | Default | Required? |
| --------- | ---- | ------ | ----------- | ------- | --------- |
## Exchange auth token for JWT
#### Method
| URI | Protocol | Method | Authentication |
| --- | -------- | ------ | -------------- |
| `/auth/exchange` | HTTP/S | POST | |
#### Request parameters
| Parameter | Type | Method | Description | Default | Required? |
| --------- | ---- | ------ | ----------- | ------- | --------- |
| token | string | POST | Token to be exchanged for JWT | N/A | YES |
## Logout
#### Method
-5
View File
@@ -1,9 +1,5 @@
package auth
import (
"net/http"
)
type (
Identifiable interface {
Identity() uint64
@@ -12,6 +8,5 @@ type (
TokenEncoder interface {
Encode(identity Identifiable) string
SetCookie(w http.ResponseWriter, r *http.Request, identity Identifiable)
}
)
-19
View File
@@ -68,22 +68,3 @@ func (t *token) Authenticator() func(http.Handler) http.Handler {
})
}
}
// Extracts and authenticates JWT from context, validates claims
func (t *token) SetCookie(w http.ResponseWriter, r *http.Request, identity Identifiable) {
cookie := &http.Cookie{
Name: "jwt",
Expires: time.Now().Add(time.Duration(t.expiry) * time.Minute),
Secure: r.URL.Scheme == "https",
Domain: t.cookieDomain,
Path: "/",
}
if identity == nil {
cookie.Expires = time.Unix(0, 0)
} else {
cookie.Value = t.Encode(identity)
}
http.SetCookie(w, cookie)
}
+2 -2
View File
@@ -64,7 +64,7 @@ func (s service) GetGlobalString(name string) (out string, err error) {
const global = 0
var v *Value
if v, err = s.repository.Get(name, global); err == nil {
if v, err = s.repository.Get(name, global); err == nil && v != nil {
err = v.Value.Unmarshal(&out)
}
@@ -75,7 +75,7 @@ func (s service) GetGlobalBool(name string) (out bool, err error) {
const global = 0
var v *Value
if v, err = s.repository.Get(name, global); err == nil {
if v, err = s.repository.Get(name, global); err == nil && v != nil {
err = v.Value.Unmarshal(&out)
}
+5 -1
View File
@@ -190,10 +190,14 @@ func settingsAutoConfigure(setSvc settings.Service, systemApiUrl, frontendUrl, f
setIfMissing("auth.frontend.url.email-confirmation", func() interface{} {
return frontendUrl + "/auth/confirm-email?token="
})
setIfMissing("auth.frontend.url.redirect", func() interface{} {
return frontendUrl + "/auth/"
})
}
// Auth email (password reset, email confirmation)
setIfMissing("auth.frontend.url.email-confirmation", func() interface{} {
setIfMissing("auth.mail.from-address", func() interface{} {
if len(fromAddress) > 0 {
return fromAddress
}
+29
View File
@@ -35,12 +35,15 @@ type (
With(ctx context.Context) AuthService
External(profile goth.User) (*types.User, error)
FrontendRedirectURL() string
InternalSignUp(input *types.User, password string) (*types.User, error)
InternalLogin(email string, password string) (*types.User, error)
SetPassword(userID uint64, newPassword string) error
ChangePassword(userID uint64, oldPassword, newPassword string) error
IssueAuthRequestToken(user *types.User) (token string, err error)
ValidateAuthRequestToken(token string) (user *types.User, err error)
ValidateEmailConfirmationToken(token string) (user *types.User, err error)
ExchangePasswordResetToken(token string) (user *types.User, exchangedToken string, err error)
ValidatePasswordResetToken(token string) (user *types.User, err error)
@@ -54,6 +57,7 @@ const (
credentialsTypeEmailAuthToken = "email-authentication-token"
credentialsTypeResetPasswordToken = "password-reset-token"
credentialsTypeResetPasswordTokenExchanged = "password-reset-token-exchanged"
credentialsTypeAuthToken = "auth-token"
credentialsTokenLength = 32
)
@@ -227,6 +231,11 @@ func (svc *auth) External(profile goth.User) (u *types.User, err error) {
})
}
// FrontendRedirectURL - a proxy to frontend redirect url setting
func (svc auth) FrontendRedirectURL() string {
return svc.settings.frontendUrlRedirect
}
// InternalSignUp protocol
//
// Forgiving but strict: valid existing users get notified
@@ -549,6 +558,14 @@ func (svc auth) changePassword(userID uint64, hash []byte) (err error) {
return errors.Wrap(err, "could not create new password")
}
func (svc auth) IssueAuthRequestToken(user *types.User) (token string, err error) {
return svc.createUserToken(user, credentialsTypeAuthToken)
}
func (svc auth) ValidateAuthRequestToken(token string) (user *types.User, err error) {
return svc.loadUserFromToken(token, credentialsTypeAuthToken)
}
func (svc auth) ValidateEmailConfirmationToken(token string) (user *types.User, err error) {
if !svc.settings.internalEnabled {
return nil, errors.New("internal authentication disabled")
@@ -764,10 +781,22 @@ func (svc auth) validateToken(token string) (ID uint64, credentials string, err
}
func (svc auth) createUserToken(user *types.User, kind string) (token string, err error) {
var expiresAt time.Time
switch kind {
case credentialsTypeAuthToken:
// 15 sec expiration for all tokens that are part of redirction
expiresAt = svc.now().Add(time.Second * 15)
default:
// 1h expiration for all tokens send via email
expiresAt = svc.now().Add(time.Minute * 60)
}
c, err := svc.credentials.Create(&types.Credentials{
OwnerID: user.ID,
Kind: kind,
Credentials: string(rand.Bytes(credentialsTokenLength)),
ExpiresAt: &expiresAt,
})
if err != nil {
+17
View File
@@ -14,6 +14,9 @@ type (
// EmailAddress confirmation path (<frontend email confirmation url> "?token=" + <token>)
frontendUrlEmailConfirmation string
// Where to redirect user after external auth flow
frontendUrlRedirect string
mailFromAddress string
mailFromName string
@@ -46,6 +49,7 @@ func AuthSettings(kv authSettingsStore) authSettings {
return authSettings{
frontendUrlPasswordReset: kv.String("auth.frontend.url.password-reset"),
frontendUrlEmailConfirmation: kv.String("auth.frontend.url.email-confirmation"),
frontendUrlRedirect: kv.String("auth.frontend.url.redirect"),
mailFromAddress: kv.String("auth.mail.from-address"),
mailFromName: kv.String("auth.mail.from-name"),
@@ -78,6 +82,19 @@ func (s authSettings) Format() map[string]interface{} {
label = strings.SplitN(p, ".", 2)[1]
}
switch label {
case "crust-iam":
label = "Crust IAM"
case "facebook":
label = "Facebook"
case "gplus":
label = "Google"
case "linkedin":
label = "LinkedIn"
case "github":
label = "GitHub"
}
providers = append(providers, externalProvider{
Label: label,
Handle: p,
+23 -9
View File
@@ -21,20 +21,28 @@ type (
Auth struct {
jwt auth.TokenEncoder
authSettings authServiceSettingsProvider
authSvc service.AuthService
}
authServiceSettingsProvider interface {
Format() map[string]interface{}
}
exchangeResponse struct {
JWT string `json:"jwt"`
User *outgoing.User `json:"user"`
}
checkResponse struct {
User *outgoing.User `json:"user"`
}
)
func (Auth) New() *Auth {
func (Auth) New(tenc auth.TokenEncoder) *Auth {
return &Auth{
jwt: tenc,
authSettings: service.DefaultAuthSettings,
authSvc: service.DefaultAuth,
}
}
@@ -43,17 +51,29 @@ func (ctrl *Auth) Check(ctx context.Context, r *request.AuthCheck) (interface{},
}
func (ctrl *Auth) Logout(ctx context.Context, r *request.AuthLogout) (interface{}, error) {
return nil, errors.New("Not implemented: Auth.logout")
return nil, nil
}
func (ctrl *Auth) Settings(ctx context.Context, r *request.AuthSettings) (interface{}, error) {
return ctrl.authSettings.Format(), nil
}
func (ctrl *Auth) ExchangeAuthToken(ctx context.Context, r *request.AuthExchangeAuthToken) (interface{}, error) {
user, err := ctrl.authSvc.ValidateAuthRequestToken(r.Token)
if err != nil {
return nil, err
}
return exchangeResponse{
JWT: ctrl.jwt.Encode(user),
User: payload.User(user),
}, nil
}
// Handlers() func ignores "std" crust controllers
//
// Crush handlers are too abstract for our auth needs so we need (direct access to htt.ResponseWriter)
func (ctrl *Auth) Handlers(jwtEncoder auth.TokenEncoder) *handlers.Auth {
func (ctrl *Auth) Handlers() *handlers.Auth {
h := handlers.NewAuth(ctrl)
// Check JWT if valid
h.Check = func(w http.ResponseWriter, r *http.Request) {
@@ -61,8 +81,6 @@ func (ctrl *Auth) Handlers(jwtEncoder auth.TokenEncoder) *handlers.Auth {
if identity := auth.GetIdentityFromContext(ctx); identity != nil && identity.Valid() {
if user, err := service.DefaultUser.With(ctx).FindByID(identity.Identity()); err == nil {
jwtEncoder.SetCookie(w, r, user)
resputil.JSON(w, checkResponse{
User: payload.User(user),
})
@@ -72,9 +90,5 @@ func (ctrl *Auth) Handlers(jwtEncoder auth.TokenEncoder) *handlers.Auth {
}
}
h.Logout = func(w http.ResponseWriter, r *http.Request) {
// nothing to do here...
}
return h
}
+53 -11
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"net/http"
"net/url"
"strings"
"time"
@@ -37,9 +38,9 @@ func NewSocial(jwtEncoder auth.TokenEncoder) *ExternalAuth {
func (ctrl *ExternalAuth) MountRoutes(r chi.Router) {
// Make sure we're backwards compatible and redirect /oidc to /auth/external/openid-connect-didmos2
// Make sure we're backwards compatible and redirect /oidc to /auth/external/openid-connect.crust-iam
r.Get("/oidc", func(w http.ResponseWriter, req *http.Request) {
http.Redirect(w, req, externalAuthBaseUrl+"/openid-connect-didmos2", http.StatusMovedPermanently)
http.Redirect(w, req, externalAuthBaseUrl+"/openid-connect.crust-iam", http.StatusMovedPermanently)
})
// Copy provider from path (Chi URL param) to request context and return it
@@ -51,7 +52,8 @@ func (ctrl *ExternalAuth) MountRoutes(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
r = copyProviderToContext(r)
// Always set redir cookie, even if not requested. If param is empty, cookie is removed
// Always set redir cookie, even if not requested.
// If param is empty, cookie will be removed
ctrl.setSessionCookie(w, r, "redir", r.URL.Query().Get("redir"))
// try to get the user without re-authenticating
@@ -102,22 +104,62 @@ func (ctrl *ExternalAuth) handleFailedCallback(w http.ResponseWriter, r *http.Re
// Handles authentication via external auth providers of
// unknown an user + appending authentication on external providers
// to a current user
//
//
// Redirection rules:
// 1) use cookie (set from query-string param on first step
// 2) use `auth.frontend.url.redirect` setting
// 3) use current url
func (ctrl *ExternalAuth) handleSuccessfulAuth(w http.ResponseWriter, r *http.Request, cred goth.User) {
log.Printf("Successful external login: %v", cred)
if u, err := ctrl.auth.With(r.Context()).External(cred); err != nil {
svc := ctrl.auth.With(r.Context())
if u, err := svc.External(cred); err != nil {
resputil.JSON(w, err)
} else {
ctrl.jwtEncoder.SetCookie(w, r, u)
if c, err := r.Cookie("redir"); c != nil && err == nil {
ctrl.setSessionCookie(w, r, "redir", "")
w.Header().Set("Location", c.Value)
w.WriteHeader(http.StatusSeeOther)
var (
token string
redirUrl *url.URL
c *http.Cookie
)
if c, err = r.Cookie("redir"); c != nil && err == nil {
if redirUrl, err = url.Parse(c.Value); err == nil {
// @todo validate origin/redir-domain
ctrl.setSessionCookie(w, r, "redir", "")
}
} else if fru := svc.FrontendRedirectURL(); fru != "" {
redirUrl, err = url.Parse(fru)
} else {
redirUrl = r.URL
}
if err != nil {
resputil.JSON(w, err)
return
}
if redirUrl != nil {
q := redirUrl.Query()
if u != nil {
if token, err = svc.IssueAuthRequestToken(u); err == nil {
q.Set("token", token)
}
}
if err != nil {
q.Set("err", err.Error())
}
redirUrl.RawQuery = q.Encode()
w.Header().Set("Location", redirUrl.String())
w.WriteHeader(http.StatusSeeOther)
}
resputil.JSON(w, u, err)
}
}
+6 -1
View File
@@ -124,5 +124,10 @@ func (ctrl *AuthInternal) ChangePassword(ctx context.Context, r *request.AuthInt
return nil, errors.New("invalid user (not authenticated)")
}
return nil, ctrl.authSvc.ChangePassword(identity.Identity(), r.OldPassword, r.NewPassword)
err := ctrl.authSvc.ChangePassword(identity.Identity(), r.OldPassword, r.NewPassword)
if err != nil {
return nil, err
} else {
return true, nil
}
}
+13 -3
View File
@@ -30,14 +30,16 @@ import (
type AuthAPI interface {
Settings(context.Context, *request.AuthSettings) (interface{}, error)
Check(context.Context, *request.AuthCheck) (interface{}, error)
ExchangeAuthToken(context.Context, *request.AuthExchangeAuthToken) (interface{}, error)
Logout(context.Context, *request.AuthLogout) (interface{}, error)
}
// HTTP API interface
type Auth struct {
Settings func(http.ResponseWriter, *http.Request)
Check func(http.ResponseWriter, *http.Request)
Logout func(http.ResponseWriter, *http.Request)
Settings func(http.ResponseWriter, *http.Request)
Check func(http.ResponseWriter, *http.Request)
ExchangeAuthToken func(http.ResponseWriter, *http.Request)
Logout func(http.ResponseWriter, *http.Request)
}
func NewAuth(ah AuthAPI) *Auth {
@@ -56,6 +58,13 @@ func NewAuth(ah AuthAPI) *Auth {
return ah.Check(r.Context(), params)
})
},
ExchangeAuthToken: func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
params := request.NewAuthExchangeAuthToken()
resputil.JSON(w, params.Fill(r), func() (interface{}, error) {
return ah.ExchangeAuthToken(r.Context(), params)
})
},
Logout: func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
params := request.NewAuthLogout()
@@ -71,6 +80,7 @@ func (ah *Auth) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http
r.Use(middlewares...)
r.Get("/auth/", ah.Settings)
r.Get("/auth/check", ah.Check)
r.Post("/auth/exchange", ah.ExchangeAuthToken)
r.Get("/auth/logout", ah.Logout)
})
}
+46
View File
@@ -110,6 +110,52 @@ func (auReq *AuthCheck) Fill(r *http.Request) (err error) {
var _ RequestFiller = NewAuthCheck()
// Auth exchangeAuthToken request parameters
type AuthExchangeAuthToken struct {
Token string
}
func NewAuthExchangeAuthToken() *AuthExchangeAuthToken {
return &AuthExchangeAuthToken{}
}
func (auReq *AuthExchangeAuthToken) Fill(r *http.Request) (err error) {
if strings.ToLower(r.Header.Get("content-type")) == "application/json" {
err = json.NewDecoder(r.Body).Decode(auReq)
switch {
case err == io.EOF:
err = nil
case err != nil:
return errors.Wrap(err, "error parsing http request body")
}
}
if err = r.ParseForm(); err != nil {
return err
}
get := map[string]string{}
post := map[string]string{}
urlQuery := r.URL.Query()
for name, param := range urlQuery {
get[name] = string(param[0])
}
postVars := r.Form
for name, param := range postVars {
post[name] = string(param[0])
}
if val, ok := post["token"]; ok {
auReq.Token = val
}
return err
}
var _ RequestFiller = NewAuthExchangeAuthToken()
// Auth logout request parameters
type AuthLogout struct {
}
+1 -1
View File
@@ -13,7 +13,7 @@ func MountRoutes(jwtEncoder auth.TokenEncoder) func(chi.Router) {
NewSocial(jwtEncoder).MountRoutes(r)
// Provide raw `/auth` handlers
Auth{}.New().Handlers(jwtEncoder).MountRoutes(r)
Auth{}.New(jwtEncoder).Handlers().MountRoutes(r)
handlers.NewAuthInternal((AuthInternal{}).New(jwtEncoder)).MountRoutes(r)
+1 -1
View File
@@ -23,5 +23,5 @@ type (
)
func (u *Credentials) Valid() bool {
return u.ID > 0 && (u.ExpiresAt == nil || u.ExpiresAt.Before(time.Now())) && u.DeletedAt == nil
return u.ID > 0 && (u.ExpiresAt == nil || u.ExpiresAt.After(time.Now())) && u.DeletedAt == nil
}