snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s

This commit is contained in:
upstream-mirror
2026-09-18 01:22:23 +05:00
commit 7e385fc126
8391 changed files with 1766236 additions and 0 deletions

View File

@@ -0,0 +1,110 @@
package handlers
import (
"sort"
"strconv"
"strings"
"time"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/pkg/errors"
"github.com/cortezaproject/corteza/server/system/types"
)
type (
authClients []authClient
authClient struct {
ID uint64
Name string
Description string
ConfirmedAt time.Time
}
)
// Will sort clients - in order of creation
var _ sort.Interface = authClients{}
func (set authClients) Len() int { return len(set) }
func (set authClients) Swap(i, j int) { set[i], set[j] = set[j], set[i] }
func (set authClients) Less(i, j int) bool {
return strings.Compare(set[i].Name, set[i].Name) < 0
}
func (h *AuthHandlers) clientsView(req *request.AuthReq) error {
req.Template = TmplAuthorizedClients
ss, err := h.getAuthorizedClients(req)
if err != nil {
return err
}
sort.Sort(ss)
req.Data["authorizedClients"] = ss
return nil
}
func (h *AuthHandlers) clientsProc(req *request.AuthReq) error {
switch {
case len(req.Request.PostFormValue("revoke")) > 0:
clientID, err := strconv.ParseUint(req.Request.PostFormValue("revoke"), 10, 64)
if clientID == 0 {
return err
}
if err = h.TokenService.DeleteByID(req.Context(), clientID); err != nil {
return err
}
if err = h.ClientService.Revoke(req.Context(), req.AuthUser.User.ID, clientID); err != nil {
return err
}
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "primary",
Text: t("authorized-clients.alerts.removed"),
})
}
req.RedirectTo = GetLinks().AuthorizedClients
return nil
}
func (h *AuthHandlers) getAuthorizedClients(req *request.AuthReq) (ss authClients, err error) {
var (
set types.AuthConfirmedClientSet
client *types.AuthClient
)
if set, err = h.ClientService.Confirmed(req.Context(), req.AuthUser.User.ID); err != nil {
return
}
ss = make(authClients, 0, len(set))
for i := range set {
client, err = h.ClientService.Lookup(req.Context(), set[i].ClientID)
if errors.IsNotFound(err) {
err = nil
continue
}
if err != nil {
return
}
ac := authClient{
ID: set[i].ClientID,
Name: client.Handle,
ConfirmedAt: set[i].ConfirmedAt,
}
if client.Meta != nil {
ac.Name = client.Meta.Name
}
ss = append(ss, ac)
}
return
}

View File

@@ -0,0 +1,54 @@
package handlers
import (
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/system/service"
"go.uber.org/zap"
)
func (h *AuthHandlers) changePasswordForm(req *request.AuthReq) error {
h.Log.Debug("showing password change form")
req.Template = TmplChangePassword
req.Data["form"] = req.PopKV()
return nil
}
func (h *AuthHandlers) changePasswordProc(req *request.AuthReq) (err error) {
err = h.AuthService.ChangePassword(
auth.SetIdentityToContext(req.Context(), req.AuthUser.User),
req.AuthUser.User.ID,
req.Request.PostFormValue("oldPassword"),
req.Request.PostFormValue("newPassword"),
)
t := translator(req, "auth")
if err == nil {
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "primary",
Text: t("change-password.alerts.password-change-success"),
})
req.RedirectTo = GetLinks().Profile
return nil
}
switch {
case service.AuthErrInternalLoginDisabledByConfig().Is(err),
service.AuthErrPasswordNotSecure().Is(err),
service.AuthErrPasswordChangeFailedForUnknownUser().Is(err),
service.AuthErrPasswordResetFailedOldPasswordCheckFailed().Is(err):
req.SetKV(map[string]string{
"error": err.Error(),
})
req.RedirectTo = GetLinks().ChangePassword
h.Log.Warn("handled error", zap.Error(err))
return nil
default:
h.Log.Error("unhandled error", zap.Error(err))
return err
}
}

View File

@@ -0,0 +1,149 @@
package handlers
import (
"context"
"net/http"
"net/url"
"testing"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/stretchr/testify/require"
)
func Test_changePasswordForm_setValues(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{}
authService *mockAuthService
authHandlers *AuthHandlers
authReq *request.AuthReq
rq = require.New(t)
)
service.CurrentSettings = &types.AppSettings{}
service.CurrentSettings.Auth.Internal.Enabled = true
authSettings := &settings.Settings{}
authService = prepareClientAuthService()
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
payload := map[string]string{"key": "value"}
authReq.SetKV(payload)
err := authHandlers.changePasswordForm(authReq)
rq.NoError(err)
rq.Equal(TmplChangePassword, authReq.Template)
rq.Equal(payload, authReq.Data["form"])
}
func Test_changePasswordProc(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{}
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
)
service.CurrentSettings = &types.AppSettings{}
tcc := []testingExpect{
{
name: "successful password change",
payload: map[string]string(nil),
alerts: []request.Alert{{Type: "primary", Text: "change-password.alerts.password-change-success", Html: ""}},
link: GetLinks().Profile,
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
changePassword: func(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) {
return nil
},
}
},
},
{
name: "provided password is not secure",
payload: map[string]string{"error": "provided password is not secure; use longer password to follow the password policy"},
link: GetLinks().ChangePassword,
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
changePassword: func(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) {
return service.AuthErrPasswordNotSecure()
},
}
},
},
{
name: "internal login is not enabled",
payload: map[string]string{"error": "internal login (username/password) is disabled"},
link: GetLinks().ChangePassword,
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
changePassword: func(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) {
return service.AuthErrInternalLoginDisabledByConfig()
},
}
},
},
{
name: "password change failed old password does not match",
payload: map[string]string{"error": "failed to change password, old password does not match"},
link: GetLinks().ChangePassword,
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
changePassword: func(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) {
return service.AuthErrPasswordResetFailedOldPasswordCheckFailed()
},
}
},
},
{
name: "password change failed for unknown user",
payload: map[string]string{"error": "failed to change password for the unknown user"},
link: GetLinks().ChangePassword,
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
changePassword: func(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) {
return service.AuthErrPasswordChangeFailedForUnknownUser()
},
}
},
},
}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
rq := require.New(t)
req.PostForm = url.Values{}
authSettings := &settings.Settings{}
tc.fn(authSettings)
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
err := authHandlers.changePasswordProc(authReq)
rq.NoError(err)
rq.Equal(tc.payload, authReq.GetKV())
if tc.alerts != nil {
rq.Equal(tc.alerts, authReq.NewAlerts)
}
rq.Equal(tc.link, authReq.RedirectTo)
})
}
}

View File

@@ -0,0 +1,117 @@
package handlers
import (
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"go.uber.org/zap"
)
var (
loginLink = GetLinks().Login
profileLink = GetLinks().Profile
createPasswordLink = GetLinks().CreatePassword
)
func (h *AuthHandlers) createPasswordForm(req *request.AuthReq) (err error) {
h.Log.Debug("password create form")
req.Template = TmplCreatePassword
// user not set, expecting valid token in URL
if token := req.Request.URL.Query().Get("token"); len(token) > 0 {
var (
user *types.User
// If user has password or not
passwordSet bool
)
user, err = h.AuthService.ValidatePasswordCreateToken(req.Context(), token)
if err == nil {
// If user does not have password (or in case there is no user with such email)
passwordSet = h.AuthService.PasswordSet(req.Context(), user.Email)
if !passwordSet {
// login user
req.AuthUser = request.NewAuthUser(h.Settings, user, false)
// redirect back to self (but without token and with user in session)
h.Log.Debug("valid password create token found, refreshing page with stored user")
req.RedirectTo = createPasswordLink
req.AuthUser.Save(req.Session)
return nil
}
}
}
if req.AuthUser == nil || err != nil {
h.Log.Warn("invalid password create token request", zap.Error(err))
h.invalidPasswordCreateAlert(req, loginLink)
return nil
}
req.Data["form"] = req.PopKV()
return nil
}
func (h *AuthHandlers) createPasswordProc(req *request.AuthReq) (err error) {
h.Log.Debug("password create proc")
err = h.AuthService.SetPassword(
auth.SetIdentityToContext(req.Context(), req.AuthUser.User),
req.AuthUser.User.ID,
req.Request.PostFormValue("password"),
)
if err == nil {
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "primary",
Text: t("create-password.alerts.password-create-success"),
})
req.RedirectTo = profileLink
return nil
}
switch {
case service.AuthErrPasswordCreateDisabledByConfig().Is(err):
h.passwordCreateDisabledAlert(req)
h.Log.Warn("handled error", zap.Error(err))
return nil
default:
h.Log.Error("unhandled error", zap.Error(err))
return err
}
}
func (h *AuthHandlers) onlyIfPasswordCreateEnabled(fn handlerFn) handlerFn {
return func(req *request.AuthReq) error {
if !h.Settings.PasswordCreateEnabled || !h.Settings.LocalEnabled {
h.passwordCreateDisabledAlert(req)
return nil
}
return fn(req)
}
}
func (h *AuthHandlers) passwordCreateDisabledAlert(req *request.AuthReq) {
req.RedirectTo = loginLink
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "danger",
Text: t("create-password.alert.password-create-disabled"),
})
}
func (h *AuthHandlers) invalidPasswordCreateAlert(req *request.AuthReq, redirectTo string) {
req.RedirectTo = redirectTo
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "warning",
Text: t("create-password.alerts.invalid-expired-password-token"),
})
}

View File

@@ -0,0 +1,181 @@
package handlers
import (
"context"
"fmt"
"net/http"
"net/url"
"testing"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/stretchr/testify/require"
)
func Test_createPasswordForm(t *testing.T) {
var (
req = &http.Request{
URL: &url.URL{},
}
user *types.User
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
)
tcc := []testingExpect{
{
name: "password create success",
payload: map[string]string(nil),
alerts: []request.Alert(nil),
link: GetLinks().CreatePassword,
template: TmplCreatePassword,
fn: func(_ *settings.Settings) {
req.URL = &url.URL{RawQuery: "token=NOT_EMPTY"}
authService = &authServiceMocked{
passwordSet: func(ctx context.Context, s string) bool {
return false
},
validatePasswordCreateToken: func(ctx context.Context, token string) (user *types.User, err error) {
u := makeMockUser()
u.SetRoles()
return u, nil
},
}
},
},
{
name: "invalid password create token",
payload: map[string]string(nil),
alerts: []request.Alert{{Type: "warning", Text: "create-password.alerts.invalid-expired-password-token"}},
link: GetLinks().Login,
template: TmplCreatePassword,
fn: func(_ *settings.Settings) {
req.URL = &url.URL{RawQuery: "token=NOT_EMPTY"}
authService = &authServiceMocked{
passwordSet: func(ctx context.Context, s string) bool {
return false
},
validatePasswordCreateToken: func(ctx context.Context, token string) (user *types.User, err error) {
return nil, fmt.Errorf("invalid token")
},
}
},
},
{
name: "invalid password create request",
payload: map[string]string(nil),
alerts: []request.Alert{{Type: "warning", Text: "create-password.alerts.invalid-expired-password-token"}},
link: GetLinks().Login,
template: TmplCreatePassword,
fn: func(_ *settings.Settings) {
req.URL = &url.URL{RawQuery: "token=NOT_EMPTY"}
user = makeMockUser()
authService = &authServiceMocked{
passwordSet: func(ctx context.Context, s string) bool {
return false
},
validatePasswordCreateToken: func(ctx context.Context, token string) (user *types.User, err error) {
return nil, fmt.Errorf("invalid token")
},
}
},
},
}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
rq := require.New(t)
// reset from previous
req.Form = url.Values{}
req.PostForm = url.Values{}
authSettings := &settings.Settings{}
tc.fn(authSettings)
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
// unset so we get to the main functionality
authReq.AuthUser = nil
err := authHandlers.createPasswordForm(authReq)
rq.NoError(err)
rq.Equal(tc.template, authReq.Template)
rq.Equal(tc.payload, authReq.GetKV())
rq.Equal(tc.alerts, authReq.NewAlerts)
rq.Equal(tc.link, authReq.RedirectTo)
})
}
}
func Test_createPasswordProc(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{}
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
)
tcc := []testingExpect{
{
name: "create password success",
payload: map[string]string(nil),
alerts: []request.Alert{{Type: "primary", Text: "create-password.alerts.password-create-success", Html: ""}},
link: GetLinks().Profile,
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
setPassword: func(ctx context.Context, userID uint64, password string) (err error) {
return nil
},
}
},
},
{
name: "create password disabled",
payload: map[string]string(nil),
alerts: []request.Alert{{Type: "danger", Text: "create-password.alert.password-create-disabled", Html: ""}},
link: GetLinks().Login,
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
setPassword: func(ctx context.Context, userID uint64, password string) (err error) {
return service.AuthErrPasswordCreateDisabledByConfig()
},
}
},
},
}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
rq := require.New(t)
authSettings := &settings.Settings{}
tc.fn(authSettings)
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
err := authHandlers.createPasswordProc(authReq)
rq.NoError(err)
rq.Equal(tc.payload, authReq.GetKV())
rq.Equal(tc.alerts, authReq.NewAlerts)
rq.Equal(tc.link, authReq.RedirectTo)
})
}
}

View File

@@ -0,0 +1,123 @@
package handlers
// Handlers dev-view for templates & scenarios
//
// See auth/README.adoc for details on how this works
import (
"fmt"
"html/template"
"net/http"
"os"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/pkg/y7s"
"gopkg.in/yaml.v3"
)
type (
devScenariosDoc []*devTemplate
devTemplate struct {
Template string
Scenes devScenes
}
devScenes []*devScene
devScene struct {
Name string
Template string
Data map[string]interface{}
}
)
func (h *AuthHandlers) devView(req *request.AuthReq) (err error) {
req.Template = "template-dev.html.tpl"
req.Data["templates"], err = getScenes(h.Opt.AssetsPath)
return
}
func (h *AuthHandlers) devSceneView(w http.ResponseWriter, r *http.Request) {
s, err := findScenario(
h.Opt.AssetsPath,
r.URL.Query().Get("template"),
r.URL.Query().Get("scene"),
)
s.Data["authBg"] = template.CSS(h.bgStylesData())
if err == nil && s != nil {
err = h.Templates.ExecuteTemplate(w, s.Template+".html.tpl", s.Data)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func findScenario(assetsPath, template, scene string) (*devScene, error) {
templates, err := getScenes(assetsPath)
if err != nil {
return nil, err
}
for _, t := range templates {
if t.Template != template {
continue
}
for _, s := range t.Scenes {
if s.Name != scene {
continue
}
return s, nil
}
}
return nil, nil
}
func getScenes(assetsPath string) (devScenariosDoc, error) {
if assetsPath == "" {
return nil, fmt.Errorf("can not load scenarios without AUTH_ASSETS_PATH and exported assets")
}
f, err := os.Open(assetsPath + "/templates/scenarios.yaml")
if err != nil {
return nil, err
}
aux := devScenariosDoc{}
return aux, yaml.NewDecoder(f).Decode(&aux)
}
func (doc *devScenariosDoc) UnmarshalYAML(n *yaml.Node) error {
return y7s.EachMap(n, func(k *yaml.Node, v *yaml.Node) (err error) {
dt := &devTemplate{Template: k.Value, Scenes: devScenes{}}
err = y7s.EachMap(v, func(k *yaml.Node, v *yaml.Node) (err error) {
s := &devScene{
Name: k.Value,
Template: dt.Template,
Data: make(map[string]interface{}),
}
if err = v.Decode(s.Data); err != nil {
return
}
dt.Scenes = append(dt.Scenes, s)
return nil
})
if err != nil {
return
}
*doc = append(*doc, dt)
return nil
})
}

View File

@@ -0,0 +1,232 @@
package handlers
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/cortezaproject/corteza/server/auth/external"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/cortezaproject/corteza/server/pkg/api"
"github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/go-chi/chi/v5"
"go.uber.org/zap"
)
func copyProviderToContext(r *http.Request) *http.Request {
return r.WithContext(context.WithValue(r.Context(), "provider", chi.URLParam(r, "provider")))
}
func (h *AuthHandlers) externalInit(w http.ResponseWriter, r *http.Request) {
r = copyProviderToContext(r)
h.Log.Info("starting external authentication flow")
beginUserAuth(w, r, external.NewDefaultExternalHandler())
}
func (h *AuthHandlers) externalCallback(w http.ResponseWriter, r *http.Request) {
r = copyProviderToContext(r)
h.Log.Info("external authentication callback")
if user, err := completeUserAuth(w, r, external.NewDefaultExternalHandler()); err != nil {
h.Log.Error("failed to complete user auth", zap.Error(err))
h.handleFailedExternalAuth(w, r, err)
} else {
h.handleSuccessfulExternalAuth(w, r, *user)
}
}
// Handles authentication via external auth providers of
// unknown an user + appending authentication on external providers
// to a current user
func (h *AuthHandlers) handleSuccessfulExternalAuth(w http.ResponseWriter, r *http.Request, cred types.ExternalAuthUser) {
h.Log.Info("login successful", zap.String("provider", cred.Provider))
switch cred.Provider {
case "saml":
h.handleSuccessfulExternalSAMLAuth(w, r, cred)
default:
h.handleSuccessfulExternalGenericAuth(w, r, cred)
}
}
// handleSuccessfulExternalSAMLAuth is a special handler just for the SAML authentication
func (h *AuthHandlers) handleSuccessfulExternalSAMLAuth(w http.ResponseWriter, r *http.Request, cred types.ExternalAuthUser) {
var (
user *types.User
err error
ctx = r.Context()
)
// Try to login/sign-up external user
if user, err = h.AuthService.External(ctx, cred); err != nil {
api.Send(w, r, err)
return
}
h.handle(func(req *request.AuthReq) error {
req.AuthUser = request.NewAuthUser(
h.Settings,
user,
// external logins are never permanent!
false,
)
// auto-complete EmailOTP and TOTP when authenticating via external identity provider
req.AuthUser.CompleteEmailOTP()
req.AuthUser.CompleteTOTP()
req.AuthUser.Save(req.Session)
handleSuccessfulAuth(req)
return nil
})(w, r)
}
// handleSuccessfulExternalGenericAuth is a generic external auth handler
// which can be used with generic auth providers to also support external API integrations
func (h *AuthHandlers) handleSuccessfulExternalGenericAuth(w http.ResponseWriter, r *http.Request, cred types.ExternalAuthUser) {
var (
user *types.User
err error
ctx = r.Context()
)
handleErr := func(err error) {
h.handleFailedExternalAuth(w, r, err)
}
// Get the provider config so we can correctly handle the provided values
p := h.getProviderConfig(cred.Provider, h.Settings.Providers)
if p == nil {
handleErr(fmt.Errorf("credentials for provider %s are not registered in the system", cred.Provider))
return
}
// For later, the request's auth user (no big deal if there isn't one)
au := request.GetAuthUser(h.SessionManager.Get(r))
// Check if we're using it as an identity provider; if so, use it to authenticate
// If no providers are specified, assume identity (legacy support)
if len(p.Usage) == 0 || p.HasUsage(types.ExternalProviderUsageIdentity) {
// Try to login/sign-up external user
if user, err = h.AuthService.External(ctx, cred); err != nil {
handleErr(err)
return
}
au = request.NewAuthUser(
h.Settings,
user,
// external logins are never permanent!
false,
)
// If that's that, cut the flow here
if !p.HasUsage(types.ExternalProviderUsageAPI) {
h.handle(func(req *request.AuthReq) error {
req.AuthUser = au
// auto-complete EmailOTP and TOTP when authenticating via external identity provider
req.AuthUser.CompleteEmailOTP()
req.AuthUser.CompleteTOTP()
req.AuthUser.Save(req.Session)
handleSuccessfulAuth(req)
return nil
})(w, r)
return
}
}
// Check if we're using it for an API integration; if so, note the access tokens
if p.HasUsage(types.ExternalProviderUsageAPI) {
if au.User == nil {
handleErr(fmt.Errorf("could not add credentials for user: not authenticated"))
return
}
ctx = auth.SetIdentityToContext(ctx, au.User)
// Look for existing
cc, err := h.CredentialsService.List(ctx, au.User.ID)
if err != nil {
handleErr(fmt.Errorf("couldn't fetch user credentials: %w", err))
return
}
// Find the existing one
kind := fmt.Sprintf("access-%s", cred.Provider)
var current *types.Credential
for _, c := range cc {
if c.Kind == kind && c.OwnerID == au.User.ID {
current = c
break
}
}
// Update
if current != nil {
current.Credentials = cred.AccessToken
_, err = h.CredentialsService.Update(ctx, current)
if err != nil {
handleErr(fmt.Errorf("couldn't update user credentials: %w", err))
return
}
} else {
_, err = h.CredentialsService.Create(ctx, &types.Credential{
Label: fmt.Sprintf("access token %s %s", cred.Provider, au.User.Email),
Kind: kind,
OwnerID: au.User.ID,
Credentials: cred.AccessToken,
})
if err != nil {
handleErr(fmt.Errorf("couldn't create user credentials: %w", err))
return
}
}
}
h.handle(func(req *request.AuthReq) error {
req.AuthUser = au
handleSuccessfulAuth(req)
return nil
})(w, r)
}
func (h *AuthHandlers) getProviderConfig(handle string, set []settings.Provider) *settings.Provider {
for _, s := range set {
if s.Handle == handle {
return &s
}
}
return nil
}
func (h *AuthHandlers) handleFailedExternalAuth(w http.ResponseWriter, _ *http.Request, err error) {
if strings.Contains(err.Error(), "Error processing your OAuth request: Invalid oauth_verifier parameter") {
// Just take user through the same loop again
w.Header().Set("Location", GetLinks().Profile)
w.WriteHeader(http.StatusSeeOther)
return
}
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprintf(w, "SSO Error: %v", err.Error())
}
func beginUserAuth(w http.ResponseWriter, r *http.Request, eh external.ExternalAuthHandler) {
eh.BeginUserAuth(w, r)
}
func completeUserAuth(w http.ResponseWriter, r *http.Request, eh external.ExternalAuthHandler) (u *types.ExternalAuthUser, err error) {
return eh.CompleteUserAuth(w, r)
}

View File

@@ -0,0 +1,79 @@
package handlers
import (
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"go.uber.org/zap"
)
func (h *AuthHandlers) acceptInviteForm(req *request.AuthReq) (err error) {
h.Log.Debug("invite email password reset form")
req.Template = TmplInvite
// user not set, expecting valid token in URL
if token := req.Request.URL.Query().Get("token"); len(token) > 0 {
var user *types.User
user, err = h.AuthService.ValidateInviteEmailToken(req.Context(), token)
if err == nil {
// login user
req.AuthUser = request.NewAuthUser(h.Settings, user, false)
if req.AuthUser.PendingEmailOTP() {
// Email OTP enabled & pending
//
// If we're here it means user clicked on a link in an email;
// we are effectively confirming email OTP
req.AuthUser.CompleteEmailOTP()
}
// redirect back to self (but without token and with user in session)
h.Log.Debug("valid user invite password reset token found, refreshing page with stored user")
req.RedirectTo = GetLinks().AcceptInvite
req.AuthUser.Save(req.Session)
return nil
}
}
if req.AuthUser == nil || err != nil {
h.Log.Warn("invalid, user invite password reset token used", zap.Error(err))
req.RedirectTo = GetLinks().Login
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "warning",
Text: t("invite.alert.invalid-expired-invite-token"),
})
}
req.Data["form"] = req.PopKV()
return nil
}
func (h *AuthHandlers) acceptInviteProc(req *request.AuthReq) (err error) {
h.Log.Debug("password reset proc")
err = h.AuthService.SetPassword(req.Context(), req.AuthUser.User.ID, req.Request.PostFormValue("password"))
if err == nil {
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "primary",
Text: t("invite.alert.success"),
})
req.RedirectTo = GetLinks().Profile
return nil
}
switch {
case service.AuthErrPasswordResetDisabledByConfig().Is(err):
h.passwordResetDisabledAlert(req)
return nil
default:
h.Log.Error("unhandled error", zap.Error(err))
return err
}
}

View File

@@ -0,0 +1,163 @@
package handlers
import (
"fmt"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/pkg/errors"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"go.uber.org/zap"
)
type (
provider struct {
Label, Handle, Icon string
}
)
func (h *AuthHandlers) loginForm(req *request.AuthReq) error {
req.Template = TmplLogin
kv := req.PopKV()
if kv == nil && h.Settings.SplitCredentialsCheck {
// Force login form to show only email input
//
// KV is nil (means that this is first load of the form)
// and credentials check is split into two parts (email first then credentials)
kv = map[string]string{
"splitCredentialsCheck": "split",
}
}
req.Data["form"] = kv
req.Data["enableRememberMe"] = h.Opt.SessionPermLifetime > 0
return nil
}
func (h *AuthHandlers) loginProc(req *request.AuthReq) (err error) {
// In most cases, we want to redirect back to login
req.RedirectTo = GetLinks().Login
req.SetKV(nil)
var (
user *types.User
email = req.Request.PostFormValue("email")
password = req.Request.PostFormValue("password")
)
err = func() (err error) {
if len(email) > 0 && len(password) == 0 && h.Settings.SplitCredentialsCheck {
// Email provided but no password and the
// split credentials check enabled
//
// SetKV w/ email will prevent the login form to show only email input
req.SetKV(map[string]string{"email": email})
// If user does not have password set (or in case there is no user with such email)
// and there is exactly one IdP we redirect user to that IdP
if !h.AuthService.PasswordSet(req.Context(), email) && len(h.Settings.Providers) == 1 {
// User w/o the password
// In case there is one single IdP automatically redirect user there
req.RedirectTo = fmt.Sprintf("%s/%s", GetLinks().External, h.Settings.Providers[0].Handle)
// for the clarity of the flow
// keeping this optional return here
return
}
// This user has existing password credentials or with more than one IdP
//
// Take user back to the login form and ask for the password
// or any other kind of login
return
}
user, err = h.AuthService.InternalLogin(req.Context(), email, password)
if err != nil {
return
}
var (
isPerm = len(req.Request.PostFormValue("keep-session")) > 0
lifetime = h.Opt.SessionLifetime
)
if isPerm {
lifetime = h.Opt.SessionPermLifetime
}
req.AuthUser = request.NewAuthUser(h.Settings, user, isPerm)
req.AuthUser.Save(req.Session)
h.Log.Info(
"login with password successful",
zap.Any("mfa", req.AuthUser.MFAStatus),
zap.Bool("perm-login", isPerm),
zap.Duration("lifetime", lifetime),
)
if req.AuthUser.PendingEmailOTP() {
// Email OTP enforced (globally or by user sec. policy)
//
// @todo this should probably be part of login in auth service?
if err = h.AuthService.SendEmailOTP(auth.SetIdentityToContext(req.Context(), req.AuthUser.User)); err != nil {
return errors.Internal("could not send OTP via email, contact your administrator").Wrap(err)
}
}
handleSuccessfulAuth(req)
return
}()
if err == nil {
return nil
}
switch {
case service.AuthErrInternalLoginDisabledByConfig().Is(err):
h.localDisabledAlert(req)
return nil
case service.AuthErrInvalidEmailFormat().Is(err),
service.AuthErrInvalidCredentials().Is(err),
service.AuthErrCredentialsLinkedToInvalidUser().Is(err):
req.SetKV(map[string]string{
"error": err.Error(),
"email": email,
})
h.Log.Warn("handled error", zap.Error(err))
return nil
default:
h.Log.Error("unhandled error", zap.Error(err))
return err
}
}
func (h *AuthHandlers) onlyIfLocalEnabled(fn handlerFn) handlerFn {
return func(req *request.AuthReq) error {
if !h.Settings.LocalEnabled {
h.localDisabledAlert(req)
return nil
}
return fn(req)
}
}
func (h *AuthHandlers) localDisabledAlert(req *request.AuthReq) {
if req.AuthUser.User != nil {
req.RedirectTo = GetLinks().Profile
} else {
req.RedirectTo = GetLinks().Login
}
t := translator(req, "auth")
req.PushDangerAlert(t("login.alert.local-disabled"))
}

View File

@@ -0,0 +1,214 @@
package handlers
import (
"context"
"net/http"
"net/url"
"testing"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/stretchr/testify/require"
)
type (
expectPayload struct {
kv map[string]string
alert []request.Alert
}
)
func Test_loginForm_setValues(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{}
authService *mockAuthService
authHandlers *AuthHandlers
authReq *request.AuthReq
rq = require.New(t)
)
service.CurrentSettings = &types.AppSettings{}
service.CurrentSettings.Auth.Internal.Enabled = true
authSettings := &settings.Settings{}
authService = prepareClientAuthService()
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
payload := map[string]string{"key": "value"}
authReq.SetKV(payload)
authHandlers.Settings = &settings.Settings{
EmailConfirmationRequired: true,
}
err := authHandlers.loginForm(authReq)
rq.NoError(err)
rq.Equal(TmplLogin, authReq.Template)
rq.Equal(payload, authReq.Data["form"])
}
func Test_loginProc(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{}
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
)
service.CurrentSettings = &types.AppSettings{}
tcc := []testingExpect{
{
name: "successful login",
payload: map[string]string(nil),
alerts: []request.Alert(nil),
link: GetLinks().Profile,
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
internalLogin: func(ctx context.Context, email, password string) (u *types.User, err error) {
u = &types.User{Meta: &types.UserMeta{}}
u.Meta.SecurityPolicy.MFA.EnforcedEmailOTP = false
u.Meta.SecurityPolicy.MFA.EnforcedTOTP = false
err = nil
return
},
}
},
},
{
name: "internal login is not enabled",
payload: map[string]string(nil),
alerts: []request.Alert{{Type: "danger", Text: "login.alert.local-disabled", Html: ""}},
link: GetLinks().Profile,
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
internalLogin: func(ctx context.Context, email, password string) (u *types.User, err error) {
err = service.AuthErrInternalLoginDisabledByConfig()
return
},
}
},
},
{
name: "invalid email format",
payload: map[string]string{"email": "email@", "error": "invalid email"},
alerts: []request.Alert(nil),
link: GetLinks().Login,
fn: func(_ *settings.Settings) {
req.PostForm.Add("email", "email@")
authService = &authServiceMocked{
internalLogin: func(ctx context.Context, email, password string) (u *types.User, err error) {
err = service.AuthErrInvalidEmailFormat()
return
},
}
},
},
{
name: "invalid credentials",
payload: map[string]string{"email": "mockuser@example.tld", "error": "invalid username and password combination"},
alerts: []request.Alert(nil),
link: GetLinks().Login,
fn: func(_ *settings.Settings) {
req.PostForm.Add("email", "mockuser@example.tld")
authService = &authServiceMocked{
internalLogin: func(ctx context.Context, email, password string) (u *types.User, err error) {
err = service.AuthErrInvalidCredentials()
return
},
}
},
},
{
name: "credentials linked to invalid user",
payload: map[string]string{"email": "mockuser@example.tld", "error": "credentials {{credentials.kind}} linked to disabled or deleted user {{user}}"},
alerts: []request.Alert(nil),
link: GetLinks().Login,
fn: func(*settings.Settings) {
req.PostForm.Add("email", "mockuser@example.tld")
authService = &authServiceMocked{
internalLogin: func(ctx context.Context, email, password string) (u *types.User, err error) {
err = service.AuthErrCredentialsLinkedToInvalidUser()
return
},
}
},
},
{
name: "split credentials check",
payload: map[string]string{"email": "mockuser@example.tld"},
alerts: []request.Alert(nil),
link: GetLinks().Login,
fn: func(authSettings *settings.Settings) {
req.PostForm.Add("email", "mockuser@example.tld")
authSettings.SplitCredentialsCheck = true
authService = &authServiceMocked{
passwordSet: func(ctx context.Context, email string) bool {
return false
},
}
},
},
{
name: "split credentials check with providers",
payload: map[string]string{"email": "mockuser@example.tld"},
alerts: []request.Alert(nil),
link: GetLinks().External + "/test-idp",
fn: func(authSettings *settings.Settings) {
req.PostForm.Add("email", "mockuser@example.tld")
authSettings.SplitCredentialsCheck = true
authSettings.Providers = []settings.Provider{
{Handle: "test-idp"},
}
authService = &authServiceMocked{
passwordSet: func(ctx context.Context, email string) bool {
return false
},
}
},
},
}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
rq := require.New(t)
// reset from previous
req.Form = url.Values{}
req.PostForm = url.Values{}
user.Meta = &types.UserMeta{}
authSettings := &settings.Settings{}
tc.fn(authSettings)
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
err := authHandlers.loginProc(authReq)
rq.NoError(err)
rq.Equal(tc.payload, authReq.GetKV())
rq.Equal(tc.alerts, authReq.NewAlerts)
rq.Equal(tc.link, authReq.RedirectTo)
})
}
}

View File

@@ -0,0 +1,32 @@
package handlers
import (
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/markbates/goth/gothic"
)
func (h *AuthHandlers) logoutProc(req *request.AuthReq) (err error) {
req.Session.Options.MaxAge = -1
if err = req.Session.Save(req.Request, req.Response); err != nil {
return
}
if err = gothic.Logout(req.Response, req.Request); err != nil {
return
}
// Prevent these two to be rendered by in the template
req.AuthUser = nil
req.Client = nil
h.Log.Info("logout successful")
req.Template = TmplLogout
req.Data["link"] = GetLinks().Login
if bl := req.Request.FormValue("back"); bl != "" {
req.Data["link"] = sanitizeLink(bl)
}
return
}

View File

@@ -0,0 +1,48 @@
package handlers
import (
"net/http"
"net/url"
"testing"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/stretchr/testify/require"
)
func Test_logoutProc(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{}
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
authSettings = &settings.Settings{}
rq = require.New(t)
)
service.CurrentSettings = &types.AppSettings{}
service.CurrentSettings.Auth.Internal.Enabled = true
authService = &authServiceMocked{}
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
req.PostForm = url.Values{}
req.PostForm.Add("back", "\"><script>alert(origin)</script><\"")
authReq.Session.Values = map[interface{}]interface{}{"key": url.Values{"key": []string{"value"}}}
err := authHandlers.logoutProc(authReq)
rq.NoError(err)
rq.Empty(authReq.Session.Values)
rq.Empty(authReq.AuthUser)
rq.Empty(authReq.Client)
rq.Equal("//scriptalert(origin)/script", authReq.Data["link"])
rq.Equal(TmplLogout, authReq.Template)
}

View File

@@ -0,0 +1,87 @@
package handlers
import (
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/pkg/auth"
)
// Handles MFA TOTP configuration form
//
// Where the TOTP QR & code are displayed and where
func (h *AuthHandlers) mfaForm(req *request.AuthReq) (err error) {
req.Template = TmplMfa
if !req.AuthUser.PendingMFA() {
req.RedirectTo = GetLinks().Profile
return nil
}
if req.Request.URL.Query().Get("action") == "resendEmailOtp" {
err = h.AuthService.SendEmailOTP(
auth.SetIdentityToContext(req.Context(), req.AuthUser.User),
)
if err != nil {
req.SetKV(map[string]string{"emailOtpError": err.Error()})
return nil
}
t := translator(req, "auth")
req.PushAlert(t("mfa.alerts.email.resent"))
req.RedirectTo = GetLinks().Mfa
}
req.Data["form"] = req.PopKV()
req.Data["emailOtpDisabled"] = req.AuthUser.DisabledEmailOTP()
req.Data["emailOtpPending"] = req.AuthUser.PendingEmailOTP()
req.Data["totpDisabled"] = req.AuthUser.DisabledTOTP()
req.Data["totpPending"] = req.AuthUser.PendingTOTP()
req.Data["totpUnconfigured"] = req.AuthUser.UnconfiguredTOTP()
return nil
}
// Handles MFA OTP form processing
func (h *AuthHandlers) mfaProc(req *request.AuthReq) (err error) {
req.RedirectTo = GetLinks().Mfa
req.SetKV(nil)
switch req.Request.Form.Get("action") {
case "verifyEmailOtp":
err = h.AuthService.ValidateEmailOTP(
auth.SetIdentityToContext(req.Context(), req.AuthUser.User),
req.Request.PostFormValue("code"),
)
if err != nil {
req.SetKV(map[string]string{"emailOtpError": err.Error()})
return nil
}
t := translator(req, "auth")
req.PushAlert(t("mfa.alerts.email.confirmed"))
req.AuthUser.CompleteEmailOTP()
case "verifyTotp":
err = h.AuthService.ValidateTOTP(
auth.SetIdentityToContext(req.Context(), req.AuthUser.User),
req.Request.PostFormValue("code"),
)
if err != nil {
req.SetKV(map[string]string{"totpError": err.Error()})
return nil
}
t := translator(req, "auth")
req.PushAlert(t("mfa.alerts.totp.confirmed"))
req.AuthUser.CompleteTOTP()
}
// All required MFA's confirmed, proceed to profile
handleSuccessfulAuth(req)
return nil
}

View File

@@ -0,0 +1,212 @@
package handlers
import (
"context"
"net/http"
"net/url"
"testing"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/stretchr/testify/require"
)
func Test_mfaProc(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{}
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
)
service.CurrentSettings = &types.AppSettings{}
tcc := []testingExpect{
{
name: "Email: successful login",
payload: map[string]string(nil),
alerts: []request.Alert{{Type: "primary", Text: "mfa.alerts.email.confirmed"}},
link: GetLinks().Profile,
fn: func(_ *settings.Settings) {
req.Form.Set("action", "verifyEmailOtp")
req.PostForm.Add("code", "123456")
authService = &authServiceMocked{
validateEmailOTP: func(ctx context.Context, code string) (err error) {
return nil
},
}
},
},
{
name: "TOTP: successful login",
payload: map[string]string(nil),
alerts: []request.Alert{{Type: "primary", Text: "mfa.alerts.totp.confirmed"}},
link: GetLinks().Mfa,
fn: func(_ *settings.Settings) {
req.Form.Set("action", "verifyTotp")
req.PostForm.Add("code", "123456")
authService = &authServiceMocked{
validateTOTP: func(ctx context.Context, code string) (err error) {
return nil
},
}
},
},
{
name: "Email: disabled",
payload: map[string]string{"emailOtpError": "multi factor authentication with email OTP is disabled"},
alerts: []request.Alert(nil),
link: GetLinks().Mfa,
fn: func(_ *settings.Settings) {
req.Form.Set("action", "verifyEmailOtp")
authService = &authServiceMocked{
validateEmailOTP: func(ctx context.Context, code string) (err error) {
return service.AuthErrDisabledMFAWithEmailOTP()
},
}
},
},
{
name: "Email: auth failed for disabled user",
payload: map[string]string{"emailOtpError": "invalid username and password combination"},
alerts: []request.Alert(nil),
link: GetLinks().Mfa,
fn: func(_ *settings.Settings) {
req.Form.Set("action", "verifyEmailOtp")
authService = &authServiceMocked{
validateEmailOTP: func(ctx context.Context, code string) (err error) {
return service.AuthErrFailedForUnknownUser()
},
}
},
},
{
name: "Email: invalid token",
payload: map[string]string{"emailOtpError": "invalid code"},
alerts: []request.Alert(nil),
link: GetLinks().Mfa,
fn: func(_ *settings.Settings) {
req.Form.Set("action", "verifyEmailOtp")
req.PostForm.Add("code", "token_TOO_LONG")
authService = &authServiceMocked{
validateEmailOTP: func(ctx context.Context, code string) (err error) {
return service.AuthErrInvalidEmailOTP()
},
}
},
},
{
name: "Email: no token in credentials db",
payload: map[string]string{"emailOtpError": "invalid code"},
alerts: []request.Alert(nil),
link: GetLinks().Mfa,
fn: func(_ *settings.Settings) {
req.Form.Set("action", "verifyEmailOtp")
req.PostForm.Add("code", "123456")
authService = &authServiceMocked{
validateEmailOTP: func(ctx context.Context, code string) (err error) {
return service.AuthErrInvalidEmailOTP()
},
}
},
},
{
name: "TOTP: disabled",
payload: map[string]string{"totpError": "multi factor authentication with TOTP is disabled"},
alerts: []request.Alert(nil),
link: GetLinks().Mfa,
fn: func(_ *settings.Settings) {
req.Form.Set("action", "verifyTotp")
authService = &authServiceMocked{
validateTOTP: func(ctx context.Context, code string) (err error) {
return service.AuthErrDisabledMFAWithTOTP()
},
}
},
},
{
name: "TOTP: auth failed for disabled user",
payload: map[string]string{"totpError": "invalid username and password combination"},
alerts: []request.Alert(nil),
link: GetLinks().Mfa,
fn: func(_ *settings.Settings) {
req.Form.Set("action", "verifyTotp")
authService = &authServiceMocked{
validateTOTP: func(ctx context.Context, code string) (err error) {
return service.AuthErrFailedForUnknownUser()
},
}
},
},
{
name: "TOTP: invalid token",
payload: map[string]string{"totpError": "invalid code"},
alerts: []request.Alert(nil),
link: GetLinks().Mfa,
fn: func(_ *settings.Settings) {
req.Form.Set("action", "verifyTotp")
req.PostForm.Add("code", "token_TOO_LONG")
authService = &authServiceMocked{
validateTOTP: func(ctx context.Context, code string) (err error) {
return service.AuthErrInvalidTOTP()
},
}
},
},
{
name: "TOTP: no token in credentials db",
payload: map[string]string{"totpError": "invalid code"},
alerts: []request.Alert(nil),
link: GetLinks().Mfa,
fn: func(_ *settings.Settings) {
req.Form.Set("action", "verifyTotp")
req.PostForm.Add("code", "123456")
authService = &authServiceMocked{
validateTOTP: func(ctx context.Context, code string) (err error) {
return service.AuthErrInvalidTOTP()
},
}
},
},
}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
rq := require.New(t)
// reset from previous
req.Form = url.Values{}
req.PostForm = url.Values{}
user.Meta = &types.UserMeta{}
authSettings := &settings.Settings{}
tc.fn(authSettings)
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
err := authHandlers.mfaProc(authReq)
rq.NoError(err)
rq.Equal(tc.payload, authReq.GetKV())
rq.Equal(tc.alerts, authReq.NewAlerts)
rq.Equal(tc.link, authReq.RedirectTo)
})
}
}

View File

@@ -0,0 +1,211 @@
package handlers
import (
"encoding/base32"
"fmt"
"math/rand"
"net/url"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/pkg/errors"
"github.com/cortezaproject/corteza/server/system/types"
"go.uber.org/zap"
"rsc.io/qr"
)
const (
// session key where the secret is kept between requests
totpSecretKey = "totpSecret"
)
// Handles MFA TOTP configuration form
//
// Where the TOTP QR & code are displayed and where
func (h *AuthHandlers) mfaTotpConfigForm(req *request.AuthReq) (err error) {
var (
rawSecret [10]byte
secret string
// this is more for debugging & development purposes
// but it does not hurt to keep it here
_, fresh = req.Request.URL.Query()["fresh"]
)
if s, has := req.Session.Values[totpSecretKey]; has && !fresh {
// secret is already in the session and
// there's no explicit request to change it
secret = s.(string)
} else {
rand.Read(rawSecret[:])
secret = base32.StdEncoding.EncodeToString(rawSecret[:])
req.Session.Values[totpSecretKey] = secret
}
req.Data["secret"] = secret
req.Data["enforced"] = h.Settings.MultiFactor.TOTP.Enforced
req.Data["form"] = req.PopKV()
req.Template = TmplMfaTotp
return nil
}
// Handles MFA OTP form processing
func (h *AuthHandlers) mfaTotpConfigProc(req *request.AuthReq) (err error) {
req.RedirectTo = GetLinks().MfaTotpNewSecret
req.SetKV(nil)
var (
user *types.User
secret, has = req.Session.Values[totpSecretKey]
)
if !has {
return fmt.Errorf("no TOTP secret in session")
}
// Here is where code validation is done and where the secret is stored
user, err = h.AuthService.ConfigureTOTP(
auth.SetIdentityToContext(req.Context(), req.AuthUser.User),
secret.(string),
req.Request.PostFormValue("code"),
)
t := translator(req, "auth")
if err == nil {
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "primary",
Text: t("mfa-totp.alerts.text-MFA-enabled"),
})
// Make sure we update User's data in the session
req.AuthUser.User = user
req.AuthUser.CompleteTOTP()
req.AuthUser.Save(req.Session)
h.Log.Info("TOTP code verified")
req.RedirectTo = GetLinks().Security
delete(req.Session.Values, totpSecretKey)
return nil
}
switch {
case errors.IsInvalidData(err):
req.SetKV(map[string]string{
"error": t("mfa-totp.errors.invalid-code-format"),
})
return nil
case errors.IsUnauthenticated(err):
req.SetKV(map[string]string{
"error": t("mfa-totp.errors.invalid-code"),
})
return nil
default:
// Just in case, delete secret if something unexpected happend
delete(req.Session.Values, totpSecretKey)
h.Log.Error("unhandled error", zap.Error(err))
return err
}
}
// Displays the QR PNG image
func (h *AuthHandlers) mfaTotpConfigQR(req *request.AuthReq) (err error) {
var (
issuer = h.Settings.MultiFactor.TOTP.Issuer
secret, has = req.Session.Values[totpSecretKey]
)
if !has {
return fmt.Errorf("no secret in session")
}
if len(issuer) == 0 {
issuer = "Corteza"
}
account := req.AuthUser.User.Email
if len(account) == 0 {
account = req.AuthUser.User.Handle
}
URL, err := url.Parse("otpauth://totp")
if err != nil {
panic(err)
}
URL.Path += "/" + url.PathEscape(issuer) + ":" + url.PathEscape(account)
params := url.Values{}
params.Add("secret", secret.(string))
params.Add("issuer", issuer)
URL.RawQuery = params.Encode()
code, err := qr.Encode(URL.String(), qr.Q)
if err != nil {
panic(err)
}
req.Status = -1
_, err = req.Response.Write(code.PNG())
return
}
// Handles MFA TOTP configuration form
//
// Where the TOTP QR & code are displayed and where
func (h *AuthHandlers) mfaTotpDisableForm(req *request.AuthReq) (err error) {
req.Data["form"] = req.PopKV()
req.Template = TmplMfaTotpDisable
return nil
}
// Handles MFA OTP form processing
func (h *AuthHandlers) mfaTotpDisableProc(req *request.AuthReq) (err error) {
req.RedirectTo = GetLinks().MfaTotpDisable
req.SetKV(nil)
var user *types.User
// Here is where code validation is done and where the secret is stored
user, err = h.AuthService.RemoveTOTP(
req.Context(),
req.AuthUser.User.ID,
req.Request.PostFormValue("code"),
)
t := translator(req, "auth")
if err == nil {
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "primary",
Text: t("mfa-totp.alerts.text-MFA-disabled"),
})
// Make sure we update User's data in the session
req.AuthUser.User = user
req.AuthUser.Update(h.Settings, user)
req.AuthUser.Save(req.Session)
h.Log.Info("TOTP disabled")
req.RedirectTo = GetLinks().Security
return nil
}
switch {
case errors.IsInvalidData(err):
req.SetKV(map[string]string{
"error": t("mfa-totp.errors.invalid-code-format"),
})
return nil
case errors.IsUnauthenticated(err):
req.SetKV(map[string]string{
"error": t("mfa-totp.errors.invalid-code"),
})
return nil
default:
h.Log.Error("unhandled error", zap.Error(err))
return err
}
}

View File

@@ -0,0 +1,708 @@
package handlers
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/cortezaproject/corteza/server/pkg/locale"
"golang.org/x/text/language"
"github.com/go-chi/jwtauth"
oauth2errors "github.com/go-oauth2/oauth2/v4/errors"
"github.com/lestrrat-go/jwx/jwa"
"github.com/lestrrat-go/jwx/jwk"
"github.com/lestrrat-go/jwx/jwt"
"github.com/spf13/cast"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/pkg/errors"
"github.com/cortezaproject/corteza/server/pkg/logger"
"github.com/cortezaproject/corteza/server/pkg/payload"
systemService "github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
oauth2def "github.com/go-oauth2/oauth2/v4"
"go.uber.org/zap"
)
// oauth2 flow authorize step
//
// OA2 server internals first run user check (see SetUserAuthorizationHandler lambda)
// to ensure user is authenticated;
func (h *AuthHandlers) oauth2Authorize(req *request.AuthReq) (err error) {
if form := request.GetOAuth2AuthParams(req.Session); form != nil {
req.Request.Form = form
h.Log.Debug("restarting oauth2 authorization flow", zap.Any("params", req.Request.Form))
} else {
h.Log.Debug("starting new oauth2 authorization flow", zap.Any("params", req.Request.Form))
}
request.SetOauth2AuthParams(req.Session, nil)
var (
client *types.AuthClient
)
if client, err = h.loadRequestedClient(req); err != nil {
return err
}
if client != nil {
// No client validation is done at this point;
// first, see if user is able to authenticate.
request.SetOauth2Client(req.Session, client)
// ensure we're dealing with client ID in case someone used handle
req.Request.Form.Set("client_id", strconv.FormatUint(client.ID, 10))
}
// set to -1 to make sure wrapping request handler
// does not send status code!
req.Status = -1
err = h.OAuth2.HandleAuthorizeRequest(req.Response, req.Request)
if err != nil {
req.Status = http.StatusInternalServerError
req.Template = TmplInternalError
req.Data["error"] = err
}
return nil
}
func (h *AuthHandlers) oauth2AuthorizeClient(req *request.AuthReq) (err error) {
var (
client = request.GetOauth2Client(req.Session)
)
if client == nil {
return fmt.Errorf("flow broken; client missing")
}
if err = client.Verify(); err != nil {
return
}
if !h.canAuthorizeClient(req.Context(), req.Client) {
h.Log.Error("user's roles do not allow authorization of this client", logger.Uint64("ID", req.Client.ID), zap.String("handle", req.Client.Handle))
request.SetOauth2Client(req.Session, nil)
request.SetOauth2AuthParams(req.Session, nil)
req.RedirectTo = GetLinks().Profile
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "danger",
Text: t("oauth2-authorize-client.alerts.denied", "client", req.Client.Meta.Name),
})
return nil
}
if !req.AuthUser.User.EmailConfirmed {
req.Data["invalidUser"] = true
req.Data["disabled"] = true
} else if client.Trusted {
h.Log.Debug("pre-authorized trusted oauth2 client")
// Client is trusted, no need to show this screen
// move forward and authorize oauth2 request
request.SetOauth2ClientAuthorized(req.Session, true)
req.RedirectTo = GetLinks().OAuth2Authorize
return nil
}
h.Log.Debug("showing oauth2 client auth form")
req.Template = TmplOAuth2AuthorizeClient
return nil
}
func (h *AuthHandlers) oauth2AuthorizeClientProc(req *request.AuthReq) (err error) {
// permissions are already check ed in the oauth2AuthorizeClient fn,
// just making sure
if h.canAuthorizeClient(req.Context(), req.Client) {
request.SetOauth2Client(req.Session, nil)
if _, allow := req.Request.Form["allow"]; allow {
request.SetOauth2ClientAuthorized(req.Session, true)
req.RedirectTo = GetLinks().OAuth2Authorize
return
}
}
// handle deny client action from authorize-client form
//
// This occurs when user pressed "DENY" button on authorize-client form
// Remove all and redirect to profile
//
request.SetOauth2AuthParams(req.Session, nil)
req.RedirectTo = GetLinks().Profile
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "warning",
Text: t("oauth2-authorize-client.alerts.denied", "client", req.Client.Meta.Name),
})
return
}
// Verifies weather current user can authorize this client or not
func (h *AuthHandlers) canAuthorizeClient(ctx context.Context, c *types.AuthClient) bool {
return systemService.DefaultAccessControl.CanAuthorizeAuthClient(ctx, c)
}
func (h *AuthHandlers) oauth2Token(req *request.AuthReq) (err error) {
// Cleanup
request.SetOauth2ClientAuthorized(req.Session, false)
req.Status = -1
client, err := h.loadRequestedClient(req)
if err != nil {
return h.tokenError(req.Response, err)
}
return h.handleTokenRequest(req, client)
}
// oauth2Info handler validates token and responds with decoded claims
func (h *AuthHandlers) oauth2Info(w http.ResponseWriter, r *http.Request) {
var (
jt jwt.Token
claims map[string]interface{}
)
err := func() (err error) {
if jt, claims, err = jwtauth.FromContext(r.Context()); err != nil {
return
}
if err = auth.TokenIssuer.Validate(r.Context(), jt); err != nil {
return
}
return nil
}()
if err != nil {
if errors.Is(err, context.Canceled) {
// Gracefully handle request cancellation
//
// This happens during the login procedure when browser is
// sent through a couple of quick redirects that can
// terminate requests
w.WriteHeader(http.StatusNoContent)
return
}
var (
data = make(map[string]interface{})
code int
header http.Header
)
if errors.Is(err, oauth2errors.ErrInvalidAccessToken) {
code = http.StatusForbidden
data["error"] = err.Error()
} else {
data, code, header = h.OAuth2.GetErrorData(err)
}
_ = header.Write(w)
w.WriteHeader(code)
data["active"] = false
_ = json.NewEncoder(w).Encode(data)
return
}
_ = json.NewEncoder(w).Encode(claims)
}
// oauth2authorizeDefaultClient acts as a proxy for default client
//
// Responsibilities:
// - handles parameterless request to initialize authorization code flow
// - accepts redirect_uri via query string that's used to build the oauth2 authorization URL
//
// (for the rest of the flow, see oauth2authorizeDefaultClientProc)
func (h *AuthHandlers) oauth2authorizeDefaultClient(req *request.AuthReq) (err error) {
if err = h.verifyDefaultClient(); err != nil {
return
}
var (
params = url.Values{}
)
params.Set("client_id", fmt.Sprintf("%d", h.DefaultClient.ID))
params.Set("response_type", "code")
params.Set("response_mode", "query")
if redir, has := req.Request.Form["redirect_uri"]; has && len(redir) > 0 {
params.Set("redirect_uri", redir[0])
}
if scope, has := req.Request.Form["scope"]; has && len(scope) > 0 {
params.Set("scope", scope[0])
}
if state, has := req.Request.Form["state"]; has && len(state) > 0 {
params.Set("state", state[0])
}
req.RedirectTo = GetLinks().OAuth2Authorize + "?" + params.Encode()
return
}
// oauth2authorizeDefaultClient acts as a proxy for default client
//
// Responsibilities:
// - handles exchange of authorization-code for token
// - handles issuing of new access token requests
//
// (for the first part of the flow, see oauth2authorizeDefaultClient)
func (h *AuthHandlers) oauth2authorizeDefaultClientProc(req *request.AuthReq) (err error) {
if err = h.verifyDefaultClient(); err != nil {
return
}
var (
// Clone of the initial request
// that we'll use for token request validation
r = req.Request.Clone(req.Context())
)
if _, has := r.Form["code"]; has {
r.Form.Set("grant_type", oauth2def.AuthorizationCode.String())
} else if _, has := r.Form["refresh_token"]; has {
r.Form.Set("grant_type", oauth2def.Refreshing.String())
} else {
// make sure we do not get surprised, remove grant_type param
// and let oauth2 internals invalidate the request
r.Form.Del("grant_type")
}
// Add id and secret from the default client
r.SetBasicAuth(
strconv.FormatUint(h.DefaultClient.ID, 10),
h.DefaultClient.Secret,
)
req.Request = r
return h.handleTokenRequest(req, h.DefaultClient)
}
func (h *AuthHandlers) verifyDefaultClient() error {
if h.DefaultClient == nil {
return fmt.Errorf("no default client configured")
}
if err := h.DefaultClient.Verify(); err != nil {
return fmt.Errorf("invalid client: %w", err)
}
return nil
}
// loads client from the request params and verifies other request params against client settings
func (h *AuthHandlers) loadRequestedClient(req *request.AuthReq) (client *types.AuthClient, err error) {
return client, func() (err error) {
var (
id string
found bool
)
if id, _, found = req.Request.BasicAuth(); !found {
if _, found = req.Request.Form["client_id"]; !found {
return
} else {
id = req.Request.Form.Get("client_id")
}
}
client, err = h.ClientService.Lookup(req.Context(), id)
if err != nil {
return fmt.Errorf("invalid client: %w", err)
}
h.Log.Debug("client loaded from store", logger.Uint64("ID", client.ID))
return
}()
}
func (h *AuthHandlers) handleTokenRequest(req *request.AuthReq, client *types.AuthClient) error {
var (
r = req.Request
w = req.Response
ctx = req.Context()
user *types.User
)
req.Status = -1
if err := client.Verify(); err != nil {
return h.tokenError(w, fmt.Errorf("invalid client: %w", err))
}
r = req.Request.Clone(ctx)
gt, tgr, err := h.OAuth2.ValidationTokenRequest(r)
if err != nil {
return h.tokenError(w, err)
}
// Manually set the impersonating user & roles when client credentials
if gt == oauth2def.ClientCredentials {
tgr.UserID = strings.Join(
append([]string{strconv.FormatUint(client.Security.ImpersonateUser, 10)}, client.Security.ForcedRoles...),
" ",
)
}
ti, err := h.OAuth2.GetAccessToken(ctx, gt, tgr)
if err != nil {
return h.tokenError(w, err)
}
suCtx := auth.SetIdentityToContext(ctx, auth.ServiceUser())
switch gt {
case oauth2def.ClientCredentials:
// Authenticated with client credentials!
// First, validate client's security settings
if client.Security == nil || client.Security.ImpersonateUser == 0 {
return h.tokenError(w, errors.Internal("auth client security configuration invalid"))
}
// Load the user
if user, err = h.UserService.FindByAny(suCtx, client.Security.ImpersonateUser); err != nil {
return h.tokenError(w, fmt.Errorf("could not generate token for impersonated user: %v", err))
}
case oauth2def.AuthorizationCode, oauth2def.Refreshing:
userID := ti.GetUserID()
if i := strings.Index(ti.GetUserID(), " "); i > 0 {
// userID field from the token could contain encoded roles
// @todo investigate if role-encoding into user-id field is still needed?
userID = userID[:i]
}
sessionUserExists := req.AuthUser != nil && req.AuthUser.User != nil
user, err = h.UserService.FindByAny(suCtx, userID)
if err != nil {
if !errors.Is(err, systemService.UserErrNotFound()) {
return h.tokenError(w, fmt.Errorf("could not generate token: %v", err))
}
if errors.Is(err, systemService.UserErrNotFound()) && sessionUserExists {
user = req.AuthUser.User
}
}
if sessionUserExists && req.AuthUser.User.ID == cast.ToUint64(userID) {
req.AuthUser.User = user
req.AuthUser.Save(req.Session)
}
default:
return fmt.Errorf("unsupported oauth2 grant type: %v", gt)
}
var (
signed []byte
scope = strings.Split(ti.GetScope(), " ")
)
// Here set roles to signed
signed, err = auth.TokenIssuer.Sign(
auth.WithAccessToken(ti.GetAccess()),
auth.WithIdentity(user),
func(tr *auth.TokenRequest) error {
// Calculate user's roles
roles := user.Roles()
if client.Security != nil {
roles = auth.ApplyRoleSecurity(
payload.ParseUint64s(client.Security.PermittedRoles),
payload.ParseUint64s(client.Security.ProhibitedRoles),
payload.ParseUint64s(client.Security.ForcedRoles),
roles...,
)
}
tr.Roles = roles
return nil
},
auth.WithClientID(client.ID),
auth.WithScope(scope...),
)
if err != nil {
return h.tokenError(w, err)
}
// modify token info with signed JWT
// this will be sent back to the user
ti.SetAccess(string(signed))
response := h.OAuth2.GetTokenData(ti)
// include user's avatarID
if user.Meta.AvatarID != 0 {
response["avatarID"] = strconv.FormatUint(user.Meta.AvatarID, 10)
}
if h.Locale.HasLanguage(language.Make(user.Meta.PreferredLanguage)) {
response["preferred_language"] = user.Meta.PreferredLanguage
} else {
response["preferred_language"] = locale.GetAcceptLanguageFromContext(req.Context()).String()
}
if user.Labels != nil {
response["labels"] = user.Labels
} else {
response["labels"] = make(map[string]interface{})
}
//include user's theme
response["theme"] = user.Meta.Theme
// in case client is configured with "openid" scope,
// we'll add "id_token" with all required (by OIDC) details encoded
if strings.Contains(client.Scope, "openid") {
var idToken []byte
if idToken, err = generateIdToken(user, client, ti, h.Opt.BaseURL); err != nil {
return h.tokenError(w, err)
}
response["id_token"] = string(idToken)
}
return writeResponse(w, response, nil)
}
func (h *AuthHandlers) tokenError(w http.ResponseWriter, err error) error {
data, statusCode, header := h.OAuth2.GetErrorData(err)
return writeResponse(w, data, header, statusCode)
}
func (h *AuthHandlers) oauth2PublicKeys(w http.ResponseWriter, r *http.Request) {
// handle error
handleErr := func(code int, err error) {
var (
data = make(map[string]interface{})
header http.Header
)
data["error"] = err.Error()
_ = header.Write(w)
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(data)
}
// @todo determine weather to get it form user and save it in settings
raw, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
handleErr(http.StatusBadRequest, fmt.Errorf("failed to generate new RSA privatre key: %w", err))
return
}
key, err := jwk.New(raw)
if err != nil {
handleErr(http.StatusInternalServerError, fmt.Errorf("failed to create symmetric key: %w", err))
return
}
publicKey, err := key.PublicKey()
if err != nil {
handleErr(http.StatusInternalServerError, fmt.Errorf("failed to create public key: %w", err))
return
}
_ = json.NewEncoder(w).Encode(publicKey)
}
func SubSplit(ti oauth2def.TokenInfo, data map[string]interface{}) {
userIdWithRoles := strings.SplitN(ti.GetUserID(), " ", 2)
data["sub"] = userIdWithRoles[0]
if len(userIdWithRoles) > 1 {
data["roles"] = strings.Split(userIdWithRoles[1], " ")
}
}
// Generates ID token that is part of OIDC flow for doing corteza-to-corteza auth
func generateIdToken(user *types.User, client *types.AuthClient, ti oauth2def.TokenInfo, baseURL string) (_ []byte, err error) {
token := jwt.New()
if err = token.Set(jwt.IssuerKey, baseURL); err != nil {
return
}
// we do not know what the admin used for client key value
// on the receiving end, so we'll encode both,
// client's ID, and it's handle
aud := []string{strconv.FormatUint(client.ID, 10)}
if len(client.Handle) > 0 {
aud = append(aud, client.Handle)
}
if err = token.Set("aud", aud); err != nil {
return
}
if err = token.Set("user_id", strconv.FormatUint(user.ID, 10)); err != nil {
return
}
// sub is required by some clients (goth) so we'll provide it just in case
if err = token.Set("sub", strconv.FormatUint(user.ID, 10)); err != nil {
return
}
if err = token.Set("email", user.Email); err != nil {
return
}
if err = token.Set("email_verified", user.EmailConfirmed); err != nil {
return
}
if err = token.Set(jwt.ExpirationKey, now().Add(ti.GetAccessExpiresIn()).Unix()); err != nil {
return
}
if err = token.Set(jwt.IssuedAtKey, now().Unix()); err != nil {
return
}
// profile claims are only added when the token is scoped for them
if auth.CheckScope(ti.GetScope(), "profile") {
for claim, value := range profileClaims(user) {
if err = token.Set(claim, value); err != nil {
return
}
}
}
return jwt.Sign(token, jwa.HS512, []byte(client.Secret))
}
// profileClaims returns OIDC profile-scope claims for the given user
func profileClaims(user *types.User) map[string]interface{} {
cc := make(map[string]interface{})
if user.Name != "" {
cc["name"] = user.Name
}
if user.Handle != "" {
cc["preferred_username"] = user.Handle
}
if user.Meta != nil && user.Meta.PreferredLanguage != "" {
cc["locale"] = user.Meta.PreferredLanguage
}
if user.UpdatedAt != nil {
cc["updated_at"] = user.UpdatedAt.Unix()
} else if !user.CreatedAt.IsZero() {
cc["updated_at"] = user.CreatedAt.Unix()
}
return cc
}
// oauth2Userinfo returns claims about the user the access token was issued for
//
// Claims are filtered by the scope of the token; sub is always included
func (h *AuthHandlers) oauth2Userinfo(w http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
jt jwt.Token
err error
)
if jt, _, err = jwtauth.FromContext(ctx); err != nil || jt == nil {
h.userinfoError(w, fmt.Errorf("unauthorized: %v", err))
return
}
if err = auth.TokenIssuer.Validate(ctx, jt); err != nil {
h.userinfoError(w, err)
return
}
sub, _ := jt.Get("sub")
userID, _ := auth.ExtractFromSubClaim(cast.ToString(sub))
if userID == 0 {
h.userinfoError(w, fmt.Errorf("invalid sub claim"))
return
}
user, err := h.UserService.FindByAny(auth.SetIdentityToContext(ctx, auth.ServiceUser()), userID)
if err != nil {
h.userinfoError(w, fmt.Errorf("could not load user: %w", err))
return
}
scopeClaim, _ := jt.Get("scope")
var (
scope = cast.ToString(scopeClaim)
response = map[string]interface{}{
// sub is the only claim that is always returned
"sub": strconv.FormatUint(user.ID, 10),
}
)
if auth.CheckScope(scope, "profile") {
for claim, value := range profileClaims(user) {
response[claim] = value
}
}
if auth.CheckScope(scope, "email") && user.Email != "" {
response["email"] = user.Email
response["email_verified"] = user.EmailConfirmed
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(response)
}
func (h *AuthHandlers) userinfoError(w http.ResponseWriter, err error) {
h.Log.Warn("userinfo request rejected", zap.Error(err))
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
w.WriteHeader(http.StatusUnauthorized)
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"error": "invalid_token",
"error_description": err.Error(),
})
}
func writeResponse(w http.ResponseWriter, data map[string]interface{}, header http.Header, statusCode ...int) error {
w.Header().Set("Content-Type", "application/json;charset=UTF-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Pragma", "no-cache")
for key := range header {
w.Header().Set(key, header.Get(key))
}
status := http.StatusOK
if len(statusCode) > 0 && statusCode[0] > 0 {
status = statusCode[0]
}
w.WriteHeader(status)
return json.NewEncoder(w).Encode(data)
}

View File

@@ -0,0 +1,389 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/go-chi/jwtauth"
oauth2models "github.com/go-oauth2/oauth2/v4/models"
"github.com/lestrrat-go/jwx/jwt"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func Test_oauth2AuthorizeSuccess(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{
Form: url.Values{},
PostForm: url.Values{},
}
oauthService oauth2Service
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
)
tcc := []testingExpect{
{
name: "authorization success",
payload: -1,
err: "",
template: "",
fn: func(_ *settings.Settings) {
oauthService = &oauth2ServiceMocked{
handleAuthorizeRequest: func(w http.ResponseWriter, r *http.Request) error {
return nil
},
}
},
},
{
name: "authorization failure",
payload: http.StatusInternalServerError,
err: "not authorized",
template: TmplInternalError,
fn: func(_ *settings.Settings) {
oauthService = &oauth2ServiceMocked{
handleAuthorizeRequest: func(w http.ResponseWriter, r *http.Request) error {
return fmt.Errorf("not authorized")
},
}
},
},
}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
rq := require.New(t)
authSettings := &settings.Settings{}
tc.fn(authSettings)
authHandlers = &AuthHandlers{
Log: zap.NewNop(),
AuthService: authService,
Settings: authSettings,
OAuth2: oauthService,
}
authReq = prepareClientAuthReq(authHandlers, req, user)
err := authHandlers.oauth2Authorize(authReq)
rq.NoError(err)
rq.Equal(tc.template, authReq.Template)
rq.Equal(tc.payload, authReq.Status)
if tc.err != "" {
rq.EqualError(errors.New(tc.err), authReq.Data["error"].(error).Error())
}
})
}
}
func Test_oauth2AuthorizeSuccessSetParams(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{
Form: url.Values{},
PostForm: url.Values{},
}
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
authSettings = &settings.Settings{}
rq = require.New(t)
)
oauthService := &oauth2ServiceMocked{
handleAuthorizeRequest: func(w http.ResponseWriter, r *http.Request) error {
return nil
},
}
authHandlers = &AuthHandlers{
Log: zap.NewNop(),
AuthService: authService,
Settings: authSettings,
OAuth2: oauthService,
}
authReq = prepareClientAuthReq(authHandlers, req, user)
authReq.Session.Values["oauth2AuthParams"] = url.Values{"foo": []string{"bar"}}
err := authHandlers.oauth2Authorize(authReq)
rq.NoError(err)
rq.Equal("", authReq.Template)
rq.Equal(-1, authReq.Status)
rq.Equal(nil, authReq.Data["error"])
}
func Test_generateIdToken(t *testing.T) {
var (
req = require.New(t)
)
signed, err := generateIdToken(
&types.User{},
&types.AuthClient{
Secret: "correct horse battery stable",
},
&oauth2models.Token{},
"http://cortezaproject.org",
)
req.NoError(err)
req.NotEmpty(signed)
}
func Test_generateIdTokenClaims(t *testing.T) {
var (
updatedAt = time.Now()
user = &types.User{
ID: 42,
Name: "John Doe",
Handle: "jdoe",
Email: "john@example.tld",
EmailConfirmed: true,
UpdatedAt: &updatedAt,
Meta: &types.UserMeta{PreferredLanguage: "sl"},
}
client = &types.AuthClient{ID: 1, Secret: "correct horse battery stable"}
)
tcc := []struct {
name string
scope string
present []string
absent []string
}{
{
name: "without profile scope",
scope: "openid",
present: []string{"sub", "user_id", "email", "email_verified", "iat", "aud"},
absent: []string{"name", "preferred_username", "locale", "updated_at"},
},
{
name: "with profile scope",
scope: "openid profile",
present: []string{"sub", "email", "name", "preferred_username", "locale", "updated_at"},
},
}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
var (
req = require.New(t)
ti = &oauth2models.Token{}
)
ti.SetScope(tc.scope)
signed, err := generateIdToken(user, client, ti, "https://example.tld/auth")
req.NoError(err)
token, err := jwt.Parse(signed)
req.NoError(err)
for _, claim := range tc.present {
_, has := token.Get(claim)
req.True(has, "expecting claim %q", claim)
}
for _, claim := range tc.absent {
_, has := token.Get(claim)
req.False(has, "not expecting claim %q", claim)
}
req.Equal("https://example.tld/auth", token.Issuer())
req.Equal("42", token.Subject())
})
}
}
func Test_oauth2Userinfo(t *testing.T) {
var (
updatedAt = time.Now()
user = &types.User{
ID: 42,
Name: "John Doe",
Handle: "jdoe",
Email: "john@example.tld",
EmailConfirmed: true,
UpdatedAt: &updatedAt,
Meta: &types.UserMeta{PreferredLanguage: "sl"},
}
)
// swap the global token issuer for one we can sign test tokens with
original := auth.TokenIssuer
t.Cleanup(func() { auth.TokenIssuer = original })
issuer, err := auth.NewTokenIssuer(
auth.WithSecretSigner("correct horse battery stable"),
auth.WithLookup(func(ctx context.Context, tokenID string) error { return nil }),
)
require.NoError(t, err)
auth.TokenIssuer = issuer
tcc := []struct {
name string
scope string
noToken bool
notFound bool
status int
claims map[string]interface{}
absent []string
}{
{
name: "openid only",
scope: "openid",
status: http.StatusOK,
claims: map[string]interface{}{"sub": "42"},
absent: []string{"name", "preferred_username", "locale", "email", "email_verified"},
},
{
name: "profile scope",
scope: "openid profile",
status: http.StatusOK,
claims: map[string]interface{}{
"sub": "42",
"name": "John Doe",
"preferred_username": "jdoe",
"locale": "sl",
},
absent: []string{"email", "email_verified"},
},
{
name: "email scope",
scope: "openid email",
status: http.StatusOK,
claims: map[string]interface{}{
"sub": "42",
"email": "john@example.tld",
"email_verified": true,
},
absent: []string{"name", "preferred_username", "locale"},
},
{
name: "no token",
noToken: true,
status: http.StatusUnauthorized,
},
{
name: "unknown user",
scope: "openid",
notFound: true,
status: http.StatusUnauthorized,
},
}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
var (
rq = require.New(t)
r = httptest.NewRequest(http.MethodGet, "/oauth2/userinfo", nil)
rec = httptest.NewRecorder()
)
if !tc.noToken {
signed, err := auth.TokenIssuer.Sign(
auth.WithIdentity(auth.Authenticated(user.ID)),
auth.WithScope(strings.Split(tc.scope, " ")...),
)
rq.NoError(err)
token, err := jwt.Parse(signed)
rq.NoError(err)
r = r.WithContext(jwtauth.NewContext(r.Context(), token, nil))
}
h := &AuthHandlers{
Log: zap.NewNop(),
UserService: userServiceMocked{
findByAny: func(context.Context, interface{}) (*types.User, error) {
if tc.notFound {
return nil, fmt.Errorf("not found")
}
return user, nil
},
},
}
h.oauth2Userinfo(rec, r)
rq.Equal(tc.status, rec.Code)
if tc.status != http.StatusOK {
rq.Contains(rec.Header().Get("WWW-Authenticate"), "Bearer")
return
}
var response map[string]interface{}
rq.NoError(json.Unmarshal(rec.Body.Bytes(), &response))
for claim, value := range tc.claims {
rq.Equal(value, response[claim], "claim %q", claim)
}
for _, claim := range tc.absent {
rq.NotContains(response, claim)
}
})
}
}
func Test_SubSplitRoles(t *testing.T) {
type (
exp struct {
id string
i string
ii []string
}
)
var (
req = require.New(t)
ti = &oauth2models.Token{}
d = make(map[string]interface{})
tii = []exp{
{id: "1", i: "1", ii: []string{}},
{id: "1 2", i: "1", ii: []string{"2"}},
{id: "1 2 33 444", i: "1", ii: []string{"2", "33", "444"}},
}
)
for _, v := range tii {
ti.SetUserID(v.id)
SubSplit(ti, d)
req.Equal(v.i, d["sub"])
if _, is := d["roles"]; is {
req.Equal(v.ii, d["roles"])
}
}
}

View File

@@ -0,0 +1,134 @@
package handlers
import (
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/pkg/errors"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"go.uber.org/zap"
)
func (h *AuthHandlers) requestPasswordResetForm(req *request.AuthReq) error {
h.Log.Debug("showing request password reset form")
req.Template = TmplRequestPasswordReset
req.Data["form"] = req.PopKV()
return nil
}
func (h *AuthHandlers) requestPasswordResetProc(req *request.AuthReq) (err error) {
h.Log.Debug("processing password change request")
email := req.Request.PostFormValue("email")
err = h.AuthService.SendPasswordResetToken(req.Context(), email)
if err == nil || errors.IsNotFound(err) {
req.RedirectTo = GetLinks().PasswordResetRequested
return nil
}
switch {
case service.AuthErrPasswordResetDisabledByConfig().Is(err):
h.passwordResetDisabledAlert(req)
return nil
default:
h.Log.Error("unhandled error", zap.Error(err))
return err
}
}
func (h *AuthHandlers) passwordResetRequested(req *request.AuthReq) error {
req.Template = TmplPasswordResetRequested
return nil
}
func (h *AuthHandlers) resetPasswordForm(req *request.AuthReq) (err error) {
h.Log.Debug("password reset form")
req.Template = TmplResetPassword
// user not set, expecting valid token in URL
if token := req.Request.URL.Query().Get("token"); len(token) > 0 {
var user *types.User
user, err = h.AuthService.ValidatePasswordResetToken(req.Context(), token)
if err == nil {
// login user
req.AuthUser = request.NewAuthUser(h.Settings, user, false)
if req.AuthUser.PendingEmailOTP() {
// Email OTP enabled & pending
//
// If we're here it means user clicked on a link in an email;
// we are effectively confirming email OTP
req.AuthUser.CompleteEmailOTP()
}
// redirect back to self (but without token and with user in session
h.Log.Debug("valid password reset token found, refreshing page with stored user")
req.RedirectTo = GetLinks().ResetPassword
req.AuthUser.Save(req.Session)
return nil
}
}
if req.AuthUser == nil || err != nil {
h.Log.Warn("invalid password reset token used", zap.Error(err))
req.RedirectTo = GetLinks().RequestPasswordReset
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "warning",
Text: t("password-reset-requested.alert.invalid-expired-password-token"),
})
}
req.Data["form"] = req.PopKV()
return nil
}
func (h *AuthHandlers) resetPasswordProc(req *request.AuthReq) (err error) {
h.Log.Debug("password reset proc")
err = h.AuthService.SetPassword(req.Context(), req.AuthUser.User.ID, req.Request.PostFormValue("password"))
if err == nil {
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "primary",
Text: t("password-reset-requested.alert.password-reset-success"),
})
req.RedirectTo = GetLinks().Profile
return nil
}
switch {
case service.AuthErrPasswordResetDisabledByConfig().Is(err):
h.passwordResetDisabledAlert(req)
return nil
default:
h.Log.Error("unhandled error", zap.Error(err))
return err
}
}
func (h *AuthHandlers) onlyIfPasswordResetEnabled(fn handlerFn) handlerFn {
return func(req *request.AuthReq) error {
if !h.Settings.PasswordResetEnabled || !h.Settings.LocalEnabled {
h.passwordResetDisabledAlert(req)
return nil
}
return fn(req)
}
}
func (h *AuthHandlers) passwordResetDisabledAlert(req *request.AuthReq) {
req.RedirectTo = GetLinks().Login
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "danger",
Text: t("password-reset-requested.alert.password-reset-disabled"),
})
}

View File

@@ -0,0 +1,250 @@
package handlers
import (
"context"
"fmt"
"net/http"
"net/url"
"testing"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/stretchr/testify/require"
)
func Test_requestPasswordResetForm(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{
URL: &url.URL{},
}
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
authSettings = &settings.Settings{}
rq = require.New(t)
)
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
payload := map[string]string{"foo": "bar"}
authReq.SetKV(payload)
err := authHandlers.requestPasswordResetForm(authReq)
rq.NoError(err)
rq.Equal(TmplRequestPasswordReset, authReq.Template)
rq.Equal(authReq.Data["form"], payload)
}
func Test_resetPasswordForm(t *testing.T) {
var (
req = &http.Request{
URL: &url.URL{},
}
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
)
tcc := []testingExpect{
{
name: "request reset success",
payload: map[string]string(nil),
alerts: []request.Alert(nil),
link: GetLinks().ResetPassword,
template: TmplResetPassword,
fn: func(_ *settings.Settings) {
req.URL = &url.URL{RawQuery: "token=NOT_EMPTY"}
authService = &authServiceMocked{
validatePasswordResetToken: func(ctx context.Context, token string) (user *types.User, err error) {
u := makeMockUser()
u.SetRoles()
return u, nil
},
}
},
},
{
name: "invalid password reset token",
payload: map[string]string(nil),
alerts: []request.Alert{{Type: "warning", Text: "password-reset-requested.alert.invalid-expired-password-token"}},
link: GetLinks().RequestPasswordReset,
template: TmplResetPassword,
fn: func(_ *settings.Settings) {
req.URL = &url.URL{RawQuery: "token=NOT_EMPTY"}
authService = &authServiceMocked{
validatePasswordResetToken: func(ctx context.Context, token string) (user *types.User, err error) {
return nil, fmt.Errorf("invalid token")
},
}
},
},
}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
rq := require.New(t)
// reset from previous
req.Form = url.Values{}
req.PostForm = url.Values{}
authSettings := &settings.Settings{}
tc.fn(authSettings)
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, nil)
// unset so we get to the main functionality
authReq.AuthUser = nil
err := authHandlers.resetPasswordForm(authReq)
rq.NoError(err)
rq.Equal(tc.template, authReq.Template)
rq.Equal(tc.payload, authReq.GetKV())
rq.Equal(tc.alerts, authReq.NewAlerts)
rq.Equal(tc.link, authReq.RedirectTo)
})
}
}
func Test_requestPasswordReset(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{}
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
)
tcc := []testingExpect{
{
name: "request reset success",
payload: map[string]string(nil),
alerts: []request.Alert(nil),
link: GetLinks().PasswordResetRequested,
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
sendPasswordResetToken: func(ctx context.Context, email string) (err error) {
return nil
},
}
},
},
{
name: "request reset disabled",
payload: map[string]string(nil),
alerts: []request.Alert{{Type: "danger", Text: "password-reset-requested.alert.password-reset-disabled"}},
link: GetLinks().Login,
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
sendPasswordResetToken: func(ctx context.Context, email string) (err error) {
return service.AuthErrPasswordResetDisabledByConfig()
},
}
},
},
}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
rq := require.New(t)
// reset from previous
req.Form = url.Values{}
req.PostForm = url.Values{}
user.Meta = &types.UserMeta{}
authSettings := &settings.Settings{}
tc.fn(authSettings)
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
err := authHandlers.requestPasswordResetProc(authReq)
rq.NoError(err)
rq.Equal(tc.payload, authReq.GetKV())
rq.Equal(tc.alerts, authReq.NewAlerts)
rq.Equal(tc.link, authReq.RedirectTo)
})
}
}
func Test_requestPasswordProc(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{}
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
)
tcc := []testingExpect{
{
name: "reset password success",
payload: map[string]string(nil),
alerts: []request.Alert{{Type: "primary", Text: "password-reset-requested.alert.password-reset-success", Html: ""}},
link: GetLinks().Profile,
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
setPassword: func(ctx context.Context, userID uint64, password string) (err error) {
return nil
},
}
},
},
{
name: "reset password disabled",
payload: map[string]string(nil),
alerts: []request.Alert{{Type: "danger", Text: "password-reset-requested.alert.password-reset-disabled", Html: ""}},
link: GetLinks().Login,
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
setPassword: func(ctx context.Context, userID uint64, password string) (err error) {
return service.AuthErrPasswordResetDisabledByConfig()
},
}
},
},
}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
rq := require.New(t)
authSettings := &settings.Settings{}
tc.fn(authSettings)
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
err := authHandlers.resetPasswordProc(authReq)
rq.NoError(err)
rq.Equal(tc.payload, authReq.GetKV())
rq.Equal(tc.alerts, authReq.NewAlerts)
rq.Equal(tc.link, authReq.RedirectTo)
})
}
}

View File

@@ -0,0 +1,180 @@
package handlers
import (
"fmt"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/pkg/options"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"go.uber.org/zap"
"strings"
)
func (h *AuthHandlers) profileForm(req *request.AuthReq) (err error) {
req.Template = TmplProfile
var (
preferredLanguage string
avatarUrl string
)
u, err := h.UserService.FindByAny(req.Context(), req.AuthUser.User.ID)
if err != nil {
h.Log.Error("find user error", zap.Error(err))
return err
}
if langList := h.Locale.LocalizedList(req.Context()); len(langList) > 0 {
req.Data["languages"] = langList
preferredLanguage = langList[0].Tag.String()
if u.Meta != nil && u.Meta.PreferredLanguage != "" {
preferredLanguage = u.Meta.PreferredLanguage
}
}
avatarUrl = fmt.Sprintf("%s/system/attachment/avatar/%d/original/%s", getApiFullPath(), u.Meta.AvatarID, types.AttachmentKindAvatar)
if form := req.PopKV(); len(form) > 0 {
req.Data["form"] = form
} else {
req.Data["form"] = map[string]string{
"email": u.Email,
"handle": u.Handle,
"name": u.Name,
"preferredLanguage": preferredLanguage,
"avatarUrl": avatarUrl,
"initialTextColor": u.Meta.AvatarColor,
"initialBgColor": u.Meta.AvatarBgColor,
}
}
req.Data["emailConfirmationRequired"] = !u.EmailConfirmed && h.Settings.EmailConfirmationRequired
req.Data["avatarEnabled"] = h.Settings.ProfileAvatarEnabled
if h.Settings.ProfileAvatarEnabled {
req.Data["isAvatar"] = false
if u.Meta.AvatarKind == types.AttachmentKindAvatar {
req.Data["isAvatar"] = true
}
}
return nil
}
func (h *AuthHandlers) profileProc(req *request.AuthReq) error {
req.RedirectTo = GetLinks().Profile
req.SetKV(nil)
var (
t = translator(req, "auth")
)
u, err := h.UserService.FindByAny(req.Context(), req.AuthUser.User.ID)
if err != nil {
h.Log.Error("find user error", zap.Error(err))
return err
}
u.Handle = req.Request.PostFormValue("handle")
u.Name = req.Request.PostFormValue("name")
if pl := req.Request.PostFormValue("preferredLanguage"); pl != "" {
if u.Meta == nil {
u.Meta = &types.UserMeta{}
}
u.Meta.PreferredLanguage = pl
}
// a little workaround to inject current user as authenticated identity into context
// this way user service will pass us through.
user, err := h.UserService.Update(req.Context(), u)
// process avatar initial generation
bgColor := req.Request.PostFormValue("initial-bg")
initialColor := req.Request.PostFormValue("initial-color")
if bgColor != u.Meta.AvatarBgColor || initialColor != u.Meta.AvatarColor {
err = h.UserService.GenerateAvatar(
req.Context(),
u.ID,
bgColor,
initialColor,
)
}
// process avatar upload
_, header, _ := req.Request.FormFile("avatar")
if header != nil {
err = h.UserService.UploadAvatar(
req.Context(),
u.ID,
header,
)
}
// Process avatar delete
if req.Request.PostFormValue("avatar-delete") == "avatar-delete" {
if err = h.UserService.DeleteAvatar(req.Context(), u.ID); err != nil {
return err
}
}
if err == nil {
err = h.AuthService.LoadRoleMemberships(req.Context(), user)
}
if err == nil {
req.AuthUser.User = user
req.AuthUser.Save(req.Session)
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "primary",
Text: t("profile.alerts.profile-updated"),
})
req.RedirectTo = GetLinks().Profile
return nil
}
avatarUrl := fmt.Sprintf("%s/system/attachment/avatar/%d/original/%s", getApiFullPath(), u.Meta.AvatarID, types.AttachmentKindAvatar)
switch {
case
service.UserErrInvalidID().Is(err),
service.UserErrInvalidHandle().Is(err),
service.UserErrInvalidEmail().Is(err),
service.UserErrHandleNotUnique().Is(err),
service.UserErrNotAllowedToUpdate().Is(err),
service.AttachmentErrInvalidAvatarFileType().Is(err),
service.AttachmentErrInvalidAvatarFileSize().Is(err),
service.AttachmentErrInvalidAvatarGenerateFontFile().Is(err):
req.SetKV(map[string]string{
"error": err.Error(),
"email": u.Email,
"handle": u.Handle,
"name": u.Name,
"avatarUrl": avatarUrl,
"initialTextColor": u.Meta.AvatarColor,
"initialBgColor": u.Meta.AvatarBgColor,
})
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "danger",
Text: t("profile.alerts.profile-update-fail"),
})
h.Log.Warn("handled error", zap.Error(err))
return nil
default:
h.Log.Error("unhandled error", zap.Error(err))
return err
}
}
func getApiFullPath() (path string) {
return fmt.Sprintf("/%s", strings.TrimPrefix(options.CleanBase(options.HttpServer().BaseUrl, options.HttpServer().ApiBaseUrl), "/"))
}

View File

@@ -0,0 +1,296 @@
package handlers
import (
"context"
"fmt"
"net/http"
"net/url"
"testing"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/stretchr/testify/require"
)
func Test_profileForm(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{
URL: &url.URL{},
}
authService authService
userService userService
authHandlers *AuthHandlers
authReq *request.AuthReq
authSettings = &settings.Settings{
EmailConfirmationRequired: false,
}
rq = require.New(t)
)
userService = &userServiceMocked{
findByAny: func(ctx context.Context, i interface{}) (*types.User, error) {
u := makeMockUser()
u.ID = i.(uint64)
return u, nil
},
}
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authHandlers.UserService = userService
authReq = prepareClientAuthReq(authHandlers, req, user)
avatarUrl := fmt.Sprintf("//system/attachment/avatar/%d/original/%s", user.Meta.AvatarID, types.AttachmentKindAvatar)
userForm := map[string]string{
"email": user.Email,
"handle": user.Handle,
"name": user.Name,
"avatarUrl": avatarUrl,
"preferredLanguage": user.Meta.PreferredLanguage,
"initialTextColor": user.Meta.AvatarColor,
"initialBgColor": user.Meta.AvatarBgColor,
}
authReq.SetKV(map[string]string{})
err := authHandlers.profileForm(authReq)
rq.NoError(err)
rq.Equal(TmplProfile, authReq.Template)
rq.Equal(authReq.Data["form"], userForm)
rq.Equal(authReq.Data["emailConfirmationRequired"], false)
}
func Test_profileFormProc(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{
PostForm: url.Values{},
}
authService authService
userService userService
authHandlers *AuthHandlers
authReq *request.AuthReq
)
tcc := []testingExpect{
{
name: "success",
err: "request Content-Type isn't multipart/form-data",
alerts: []request.Alert{{Type: "primary", Text: "profile.alerts.profile-updated", Html: ""}},
link: GetLinks().Profile,
payload: map[string]string(nil),
fn: func(_ *settings.Settings) {
req.PostForm.Add("handle", "handle")
req.PostForm.Add("name", "name")
authService = &authServiceMocked{}
userService = &userServiceMocked{
update: func(c context.Context, u *types.User) (*types.User, error) {
u = makeMockUser()
u.SetRoles()
return u, nil
},
findByAny: func(ctx context.Context, i interface{}) (*types.User, error) {
u := makeMockUser()
u.ID = i.(uint64)
return u, nil
},
}
},
},
{
name: "proc invalid ID",
err: "",
alerts: []request.Alert{{Type: "danger", Text: "profile.alerts.profile-update-fail", Html: ""}},
link: GetLinks().Profile,
payload: map[string]string{
"email": "mockuser@example.tld",
"error": "invalid ID",
"handle": "handle",
"name": "name",
"initialBgColor": "",
"initialTextColor": "",
"avatarUrl": "//system/attachment/avatar/0/original/avatar",
},
fn: func(_ *settings.Settings) {
req.PostForm.Add("handle", "handle")
req.PostForm.Add("name", "name")
userService = &userServiceMocked{
update: func(c context.Context, u *types.User) (*types.User, error) {
return nil, service.UserErrInvalidID()
},
findByAny: func(ctx context.Context, i interface{}) (*types.User, error) {
u := makeMockUser()
u.ID = i.(uint64)
return u, nil
},
}
},
},
{
name: "proc invalid handle",
err: "",
alerts: []request.Alert{{Type: "danger", Text: "profile.alerts.profile-update-fail", Html: ""}},
link: GetLinks().Profile,
payload: map[string]string{
"email": "mockuser@example.tld",
"error": "invalid handle",
"handle": "handle",
"name": "name",
"initialBgColor": "",
"initialTextColor": "",
"avatarUrl": "//system/attachment/avatar/0/original/avatar",
},
fn: func(_ *settings.Settings) {
req.PostForm.Add("handle", "handle")
req.PostForm.Add("name", "name")
userService = &userServiceMocked{
update: func(c context.Context, u *types.User) (*types.User, error) {
return nil, service.UserErrInvalidHandle()
},
findByAny: func(ctx context.Context, i interface{}) (*types.User, error) {
u := makeMockUser()
u.ID = i.(uint64)
return u, nil
},
}
},
},
{
name: "proc invalid email",
err: "",
alerts: []request.Alert{{Type: "danger", Text: "profile.alerts.profile-update-fail", Html: ""}},
link: GetLinks().Profile,
payload: map[string]string{
"email": "mockuser@example.tld",
"error": "invalid email",
"handle": "handle",
"name": "name",
"initialBgColor": "",
"initialTextColor": "",
"avatarUrl": "//system/attachment/avatar/0/original/avatar",
},
fn: func(_ *settings.Settings) {
req.PostForm.Add("handle", "handle")
req.PostForm.Add("name", "name")
userService = &userServiceMocked{
update: func(c context.Context, u *types.User) (*types.User, error) {
return nil, service.UserErrInvalidEmail()
},
findByAny: func(ctx context.Context, i interface{}) (*types.User, error) {
u := makeMockUser()
u.ID = i.(uint64)
return u, nil
},
}
},
},
{
name: "proc handle not unique",
err: "",
alerts: []request.Alert{{Type: "danger", Text: "profile.alerts.profile-update-fail", Html: ""}},
link: GetLinks().Profile,
payload: map[string]string{
"email": "mockuser@example.tld",
"error": "handle not unique",
"handle": "handle",
"name": "name",
"initialBgColor": "",
"initialTextColor": "",
"avatarUrl": "//system/attachment/avatar/0/original/avatar",
},
fn: func(_ *settings.Settings) {
req.PostForm.Add("handle", "handle")
req.PostForm.Add("name", "name")
userService = &userServiceMocked{
update: func(c context.Context, u *types.User) (*types.User, error) {
return nil, service.UserErrHandleNotUnique()
},
findByAny: func(ctx context.Context, i interface{}) (*types.User, error) {
u := makeMockUser()
u.ID = i.(uint64)
return u, nil
},
}
},
},
{
name: "user.errors.notAllowedToUpdate",
err: "",
alerts: []request.Alert{{Type: "danger", Text: "profile.alerts.profile-update-fail", Html: ""}},
link: GetLinks().Profile,
payload: map[string]string{
"email": "mockuser@example.tld",
"error": "not allowed to update this user",
"handle": "handle",
"name": "name",
"initialBgColor": "",
"initialTextColor": "",
"avatarUrl": "//system/attachment/avatar/0/original/avatar",
},
fn: func(_ *settings.Settings) {
req.PostForm.Add("handle", "handle")
req.PostForm.Add("name", "name")
userService = &userServiceMocked{
update: func(c context.Context, u *types.User) (*types.User, error) {
return nil, service.UserErrNotAllowedToUpdate()
},
findByAny: func(ctx context.Context, i interface{}) (*types.User, error) {
u := makeMockUser()
u.ID = i.(uint64)
return u, nil
},
}
},
},
}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
rq := require.New(t)
// reset from previous
req.Form = url.Values{}
req.PostForm = url.Values{}
authSettings := &settings.Settings{}
tc.fn(authSettings)
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authHandlers.UserService = userService
authReq = prepareClientAuthReq(authHandlers, req, user)
err := authHandlers.profileProc(authReq)
rq.NoError(err)
rq.Equal(tc.template, authReq.Template)
rq.Equal(tc.payload, authReq.GetKV())
rq.Equal(tc.alerts, authReq.NewAlerts)
rq.Equal(tc.link, authReq.RedirectTo)
})
}
}

View File

@@ -0,0 +1,35 @@
package handlers
import (
"net/http"
"github.com/cortezaproject/corteza/server/auth/external"
"go.uber.org/zap"
)
func (h *AuthHandlers) samlInit(w http.ResponseWriter, r *http.Request) {
r = copyProviderToContext(r)
h.Log.Info("starting SAML authentication flow")
if h.SamlSPService == nil || h.SamlSPService.Handler() == nil {
h.Log.Error("SAML service not initialized")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
if !h.SamlSPService.Enabled {
h.Log.Warn("failed to start SAML authentication flow: disabled")
w.WriteHeader(http.StatusServiceUnavailable)
return
}
ex := external.NewSamlExternalHandler(h.SamlSPService)
beginUserAuth(w, r, ex)
if user, err := completeUserAuth(w, r, ex); err != nil {
h.Log.Error("failed to complete user auth", zap.Error(err))
h.handleFailedExternalAuth(w, r, err)
} else {
h.handleSuccessfulExternalAuth(w, r, *user)
}
}

View File

@@ -0,0 +1,69 @@
package handlers
import (
"github.com/cortezaproject/corteza/server/auth/request"
"go.uber.org/zap"
)
func (h *AuthHandlers) securityForm(req *request.AuthReq) error {
req.Template = TmplSecurity
// user's MFA security policy
umsp := req.AuthUser.User.Meta.SecurityPolicy.MFA
// Check if email OTP or TOTP is enforced (either by user settings or globally)
req.Data["emailOtpEnforced"] = umsp.EnforcedEmailOTP || h.Settings.MultiFactor.EmailOTP.Enforced
req.Data["totpEnforced"] = umsp.EnforcedTOTP || h.Settings.MultiFactor.TOTP.Enforced
return nil
}
func (h *AuthHandlers) securityProc(req *request.AuthReq) error {
req.RedirectTo = GetLinks().Security
action := req.Request.Form.Get("action")
switch action {
case "reconfigureTOTP", "configureTOTP":
// make sure secret is regenerated
delete(req.Session.Values, totpSecretKey)
req.RedirectTo = GetLinks().MfaTotpNewSecret
case "disableTOTP":
req.RedirectTo = GetLinks().MfaTotpDisable
case "disableEmailOTP", "enableEmailOTP":
enable := action == "enableEmailOTP"
if user, err := h.AuthService.ConfigureEmailOTP(req.Context(), req.AuthUser.User.ID, enable); err != nil {
return err
} else {
t := translator(req, "auth")
if enable {
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "primary",
Text: t("security.template.mfa.email.enabled"),
})
} else {
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "primary",
Text: t("security.template.mfa.email.disabled"),
})
}
// Make sure we update User's data in the session
req.AuthUser.User = user
req.AuthUser.Update(h.Settings, user)
// If enabling, mark as complete in the current session; required for future logins
if enable {
req.AuthUser.CompleteEmailOTP()
}
req.AuthUser.Save(req.Session)
h.Log.Info("email OTP configured", zap.Bool("enabled", enable))
}
}
return nil
}

View File

@@ -0,0 +1,159 @@
package handlers
import (
"context"
"fmt"
"net/http"
"net/url"
"testing"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/stretchr/testify/require"
)
func Test_securityForm(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{
URL: &url.URL{},
}
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
authSettings = &settings.Settings{}
rq = require.New(t)
)
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
err := authHandlers.securityForm(authReq)
rq.NoError(err)
rq.Equal(TmplSecurity, authReq.Template)
rq.Equal(true, authReq.Data["emailOtpEnforced"])
rq.Equal(false, authReq.Data["totpEnforced"])
}
func Test_securityProc(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{
PostForm: url.Values{},
}
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
)
tcc := []testingExpect{
{
name: "reconfigureTOTP",
link: GetLinks().MfaTotpNewSecret,
payload: map[interface{}]interface{}{},
fn: func(_ *settings.Settings) {
req.Form.Set("action", "reconfigureTOTP")
},
},
{
name: "disableTOTP",
link: GetLinks().MfaTotpDisable,
payload: map[interface{}]interface{}{"totpSecret": "SECRET_VALUE"},
fn: func(_ *settings.Settings) {
req.Form.Set("action", "disableTOTP")
},
},
{
name: "disableEmailOTP failure",
err: "custom error",
link: GetLinks().Security,
payload: map[interface{}]interface{}{"totpSecret": "SECRET_VALUE"},
fn: func(_ *settings.Settings) {
req.Form.Set("action", "disableEmailOTP")
authService = &authServiceMocked{
configureEmailOTP: func(c context.Context, u uint64, b bool) (user *types.User, err error) {
return nil, fmt.Errorf("custom error")
},
}
},
},
}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
rq := require.New(t)
// reset from previous
req.Form = url.Values{}
req.PostForm = url.Values{}
authSettings := &settings.Settings{}
tc.fn(authSettings)
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
authReq.Session.Values = map[interface{}]interface{}{"totpSecret": "SECRET_VALUE"}
err := authHandlers.securityProc(authReq)
rq.Equal(tc.payload, authReq.Session.Values)
rq.Equal(tc.link, authReq.RedirectTo)
if tc.alerts != nil {
rq.Equal(tc.alerts, authReq.NewAlerts)
}
if tc.err != "" {
rq.Equal(tc.err, err.Error())
}
})
}
}
func Test_securityProcDisableEmailOTPSuccess(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{
Form: url.Values{},
PostForm: url.Values{},
}
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
authSettings = &settings.Settings{}
rq = require.New(t)
)
req.Form.Set("action", "disableEmailOTP")
authService = &authServiceMocked{
configureEmailOTP: func(c context.Context, u uint64, b bool) (*types.User, error) {
return user, nil
},
}
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
authReq.Session.Values = map[interface{}]interface{}{"totpSecret": "SECRET_VALUE"}
err := authHandlers.securityProc(authReq)
rq.NoError(err)
rq.Equal([]request.Alert{{Type: "primary", Text: "security.template.mfa.email.disabled", Html: ""}}, authReq.NewAlerts)
}

View File

@@ -0,0 +1,136 @@
package handlers
import (
"crypto/md5"
"fmt"
"sort"
"time"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/system/types"
)
type (
userSessions []userSession
userSession struct {
id string
// this will store hashed ID value of the session ID + auth secret
ID string
Current bool
ExpiresAt time.Time
CreatedAt time.Time
Expired bool
ExpiresIn int
RemoteAddr string
SameRemoteAddr bool
UserAgent string
SameUserAgent bool
}
)
// Will sort sessions - current first, then the rest in order of creation
var _ sort.Interface = userSessions{}
func (set userSessions) Len() int { return len(set) }
func (set userSessions) Swap(i, j int) { set[i], set[j] = set[j], set[i] }
func (set userSessions) Less(i, j int) bool {
switch {
case set[i].Current:
return true
default:
return set[i].CreatedAt.After(set[j].CreatedAt)
}
}
func (h *AuthHandlers) sessionsView(req *request.AuthReq) error {
req.Template = TmplSessions
ss, err := h.getSessions(req)
if err != nil {
return err
}
sort.Sort(ss)
req.Data["sessions"] = ss
return nil
}
func (h *AuthHandlers) sessionsProc(req *request.AuthReq) error {
ctx := req.Context()
ss, err := h.getSessions(req)
if err != nil {
return err
}
switch {
case len(req.Request.PostFormValue("delete-all-but-current")) > 0:
for _, s := range ss {
if s.Current {
continue
}
if err = h.SessionManager.DeleteByID(ctx, s.id); err != nil {
return err
}
}
err = h.TokenService.DeleteByUserID(ctx, req.AuthUser.User.ID)
if err != nil {
return err
}
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "primary",
Text: t("sessions.alerts.delete-sessions-but-current"),
})
case len(req.Request.PostFormValue("delete")) > 0:
for _, s := range ss {
if s.ID != req.Request.PostFormValue("delete") {
continue
}
if err = h.SessionManager.DeleteByID(ctx, s.id); err != nil {
return err
}
}
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "primary",
Text: t("sessions.alerts.session-deleted"),
})
}
req.RedirectTo = GetLinks().Sessions
return nil
}
func (h *AuthHandlers) getSessions(req *request.AuthReq) (ss userSessions, err error) {
var set []*types.AuthSession
if set, err = h.SessionManager.Search(req.Context(), req.AuthUser.User.ID); err != nil {
return
} else {
ss = make(userSessions, len(set))
for i := range set {
isCurrent := req.Session.ID == set[i].ID
ss[i] = userSession{
id: set[i].ID,
ID: fmt.Sprintf("%x", md5.Sum([]byte(set[i].ID+h.Opt.Secret))),
Current: isCurrent,
CreatedAt: set[i].CreatedAt,
ExpiresAt: set[i].ExpiresAt,
Expired: set[i].ExpiresAt.Before(time.Now()),
ExpiresIn: int(set[i].ExpiresAt.Sub(time.Now()).Hours() / 24),
RemoteAddr: set[i].RemoteAddr,
SameRemoteAddr: set[i].RemoteAddr == req.Request.RemoteAddr,
UserAgent: set[i].UserAgent,
SameUserAgent: set[i].UserAgent == req.Request.UserAgent(),
}
}
}
return
}

View File

@@ -0,0 +1,190 @@
package handlers
import (
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/pkg/logger"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"go.uber.org/zap"
)
func (h *AuthHandlers) signupForm(req *request.AuthReq) error {
req.Template = TmplSignup
req.Data["form"] = req.PopKV()
return nil
}
func (h *AuthHandlers) signupProc(req *request.AuthReq) error {
req.RedirectTo = GetLinks().Signup
req.SetKV(nil)
ugID := uint64(0)
if req.Client != nil && req.Client.Security != nil {
ugID = req.Client.Security.UserGroup
}
payload := &types.User{
Email: req.Request.PostFormValue("email"),
Handle: req.Request.PostFormValue("handle"),
Name: req.Request.PostFormValue("name"),
UserGroupID: ugID,
}
newUser, err := h.AuthService.InternalSignUp(
req.Context(),
payload,
req.Request.PostFormValue("password"),
)
if err == nil {
if newUser.EmailConfirmed {
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "primary",
Text: t("signup.alerts.signup-successful"),
})
h.Log.Info(
"signup successful",
zap.String("email", newUser.Email),
logger.Uint64s("roles", newUser.Roles()),
)
// redirect to the webapp base path
req.RedirectTo = WebappBasePath
// if the client is nil, redirect to the profile
// else check if the client is trusted
if req.Client == nil {
req.RedirectTo = GetLinks().Profile
} else if !req.Client.Trusted {
req.RedirectTo = GetLinks().OAuth2AuthorizeClient
}
req.AuthUser = request.NewAuthUser(h.Settings, newUser, false)
// auto-complete EmailOTP
req.AuthUser.CompleteEmailOTP()
req.AuthUser.Save(req.Session)
} else {
req.RedirectTo = GetLinks().PendingEmailConfirmation
}
return nil
}
fallback := func(req *request.AuthReq) {
req.SetKV(map[string]string{
"error": err.Error(),
"email": payload.Email,
"handle": payload.Handle,
"name": payload.Name,
})
}
switch {
case service.AuthErrInternalSignupDisabledByConfig().Is(err):
h.signupDisabledAlert(req)
return nil
case service.AuthErrInvalidEmailFormat().Is(err),
service.AuthErrInvalidHandle().Is(err),
service.AuthErrPasswordNotSecure().Is(err),
service.AuthErrInvalidCredentials().Is(err):
fallback(req)
h.Log.Warn("handled error", zap.Error(err))
return nil
default:
fallback(req)
h.Log.Error("unhandled error", zap.Error(err))
return err
}
}
func (h *AuthHandlers) pendingEmailConfirmation(req *request.AuthReq) error {
req.Template = TmplPendingEmailConfirmation
if _, has := req.Request.URL.Query()["resend"]; has && req.AuthUser.User != nil {
err := h.AuthService.SendEmailAddressConfirmationToken(req.Context(), req.AuthUser.User)
if err != nil {
return err
}
}
return nil
}
func (h *AuthHandlers) confirmEmail(req *request.AuthReq) (err error) {
if token := req.Request.URL.Query().Get("token"); len(token) > 0 {
var user *types.User
user, err = h.AuthService.ValidateEmailConfirmationToken(req.Context(), token)
if err == nil {
// redirect back to self (but without token and with user in session
h.Log.Debug("valid email confirmation token found, redirecting to profile")
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "primary",
Text: t("signup.alerts.email-confirmed-logged-in"),
})
// redirect to the webapp base path
req.RedirectTo = WebappBasePath
if req.Client != nil && !req.Client.Trusted {
req.RedirectTo = GetLinks().OAuth2AuthorizeClient
}
req.AuthUser = request.NewAuthUser(h.Settings, user, false)
// auto-complete EmailOTP
req.AuthUser.CompleteEmailOTP()
req.AuthUser.Save(req.Session)
return nil
}
}
h.Log.Warn("invalid email confirmation token used", zap.Error(err))
// redirect to the right page
// not doing this here and relying on handler on subseq. request
// will cause alerts to be removed
if req.AuthUser == nil || req.AuthUser.User == nil {
req.RedirectTo = GetLinks().Login
} else {
req.RedirectTo = GetLinks().Profile
}
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "warning",
Text: t("signup.alerts.invalid-expired-token"),
})
return nil
}
func (h *AuthHandlers) onlyIfSignupEnabled(fn handlerFn) handlerFn {
return func(req *request.AuthReq) error {
if !h.Settings.SignupEnabled || !h.Settings.LocalEnabled {
h.signupDisabledAlert(req)
return nil
}
return fn(req)
}
}
func (h *AuthHandlers) signupDisabledAlert(req *request.AuthReq) {
req.RedirectTo = GetLinks().Login
t := translator(req, "auth")
req.NewAlerts = append(req.NewAlerts, request.Alert{
Type: "danger",
Text: t("signup.alerts.signup-disabled"),
})
}

View File

@@ -0,0 +1,241 @@
package handlers
import (
"context"
"net/http"
"net/url"
"testing"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/stretchr/testify/require"
)
func Test_signupForm(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{
URL: &url.URL{},
}
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
authSettings = &settings.Settings{}
rq = require.New(t)
)
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
userForm := map[string]string{
"email": user.Email,
"handle": user.Handle,
"name": user.Name,
}
authReq.SetKV(userForm)
err := authHandlers.signupForm(authReq)
rq.NoError(err)
rq.Equal(TmplSignup, authReq.Template)
rq.Equal(userForm, authReq.Data["form"])
}
func Test_signupProc(t *testing.T) {
var (
user = makeMockUser()
req = &http.Request{
PostForm: url.Values{},
}
authService authService
authHandlers *AuthHandlers
authReq *request.AuthReq
)
tcc := []testingExpect{
{
name: "success email confirmed",
err: "",
alerts: []request.Alert{{Type: "primary", Text: "signup.alerts.signup-successful", Html: ""}},
link: GetLinks().Profile,
payload: map[string]string(nil),
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
u = &types.User{
EmailConfirmed: true,
Meta: &types.UserMeta{},
}
u.Meta.SecurityPolicy.MFA.EnforcedEmailOTP = true
u.Meta.SecurityPolicy.MFA.EnforcedTOTP = false
err = nil
return
},
}
},
},
{
name: "success email unconfirmed",
err: "",
alerts: []request.Alert(nil),
link: GetLinks().PendingEmailConfirmation,
payload: map[string]string(nil),
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
return &types.User{EmailConfirmed: false}, nil
},
}
},
},
{
name: "internal signup disabled",
err: "",
alerts: []request.Alert{{Type: "danger", Text: "signup.alerts.signup-disabled", Html: ""}},
link: GetLinks().Login,
payload: map[string]string(nil),
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
return nil, service.AuthErrInternalSignupDisabledByConfig()
},
}
},
},
{
name: "invalid email format",
err: "",
alerts: []request.Alert(nil),
link: GetLinks().Signup,
payload: map[string]string{"email": "", "error": "invalid email", "handle": "", "name": ""},
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
return nil, service.AuthErrInvalidEmailFormat()
},
}
},
},
{
name: "invalid handle",
err: "",
alerts: []request.Alert(nil),
link: GetLinks().Signup,
payload: map[string]string{"email": "", "error": "invalid handle", "handle": "", "name": ""},
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
return nil, service.AuthErrInvalidHandle()
},
}
},
},
{
name: "password not secure",
err: "",
alerts: []request.Alert(nil),
link: GetLinks().Signup,
payload: map[string]string{"email": "", "error": "provided password is not secure; use longer password to follow the password policy", "handle": "", "name": ""},
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
return nil, service.AuthErrPasswordNotSecure()
},
}
},
},
{
name: "invalid credentials",
err: "",
alerts: []request.Alert(nil),
link: GetLinks().Signup,
payload: map[string]string{"email": "", "error": "invalid username and password combination", "handle": "", "name": ""},
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
return nil, service.AuthErrInvalidCredentials()
},
}
},
},
{
name: "handle not unique",
err: "handle not unique",
alerts: []request.Alert(nil),
link: GetLinks().Signup,
payload: map[string]string{"email": "test@mail.com", "error": "handle not unique", "handle": "test-user", "name": "Test User"},
postForm: url.Values{
"email": {"test@mail.com"},
"handle": {"test-user"},
"name": {"Test User"},
},
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
return nil, service.UserErrHandleNotUnique()
},
}
},
},
{
name: "email not unique",
err: "email not unique",
alerts: []request.Alert(nil),
link: GetLinks().Signup,
payload: map[string]string{"email": "test@mail.com", "error": "email not unique", "handle": "test-user", "name": "Test User"},
postForm: url.Values{
"email": {"test@mail.com"},
"handle": {"test-user"},
"name": {"Test User"},
},
fn: func(_ *settings.Settings) {
authService = &authServiceMocked{
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
return nil, service.UserErrEmailNotUnique()
},
}
},
},
}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
rq := require.New(t)
// reset from previous
req.Form = url.Values{}
req.PostForm = tc.postForm
authSettings := &settings.Settings{}
tc.fn(authSettings)
authHandlers = prepareClientAuthHandlers(authService, authSettings)
authReq = prepareClientAuthReq(authHandlers, req, user)
err := authHandlers.signupProc(authReq)
if len(tc.err) > 0 {
rq.Equal(tc.err, err.Error())
} else {
rq.NoError(err)
}
rq.Equal(tc.template, authReq.Template)
rq.Equal(tc.payload, authReq.GetKV())
rq.Equal(tc.alerts, authReq.NewAlerts)
rq.Equal(tc.link, authReq.RedirectTo)
})
}
}

View File

@@ -0,0 +1,537 @@
package handlers
import (
"context"
"encoding/gob"
"fmt"
"html/template"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"time"
"golang.org/x/text/language"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/auth/external"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/auth/saml"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/pkg/errors"
"github.com/cortezaproject/corteza/server/pkg/locale"
"github.com/cortezaproject/corteza/server/pkg/options"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/go-oauth2/oauth2/v4"
"github.com/go-oauth2/oauth2/v4/server"
"github.com/gorilla/csrf"
"github.com/gorilla/sessions"
"github.com/markbates/goth"
"github.com/spf13/cast"
"go.uber.org/zap"
)
type (
authService interface {
External(ctx context.Context, profile types.ExternalAuthUser) (u *types.User, err error)
InternalSignUp(ctx context.Context, input *types.User, password string) (u *types.User, err error)
InternalLogin(ctx context.Context, email string, password string) (u *types.User, err error)
SetPassword(ctx context.Context, userID uint64, password string) (err error)
ChangePassword(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error)
ValidateEmailConfirmationToken(ctx context.Context, token string) (user *types.User, err error)
ValidatePasswordResetToken(ctx context.Context, token string) (user *types.User, err error)
ValidatePasswordCreateToken(ctx context.Context, token string) (user *types.User, err error)
SendEmailAddressConfirmationToken(ctx context.Context, u *types.User) (err error)
SendPasswordResetToken(ctx context.Context, email string) (err error)
GetProviders() types.ExternalAuthProviderSet
PasswordSet(ctx context.Context, email string) (is bool)
ValidateTOTP(ctx context.Context, code string) (err error)
ConfigureTOTP(ctx context.Context, secret string, code string) (u *types.User, err error)
RemoveTOTP(ctx context.Context, userID uint64, code string) (u *types.User, err error)
LoadRoleMemberships(ctx context.Context, u *types.User) error
SendEmailOTP(ctx context.Context) (err error)
ConfigureEmailOTP(ctx context.Context, userID uint64, enable bool) (u *types.User, err error)
ValidateEmailOTP(ctx context.Context, code string) (err error)
SendInviteEmail(ctx context.Context, email string) (err error)
ValidateInviteEmailToken(ctx context.Context, token string) (user *types.User, err error)
}
credentialsService interface {
List(ctx context.Context, userID uint64) (cc types.CredentialSet, err error)
Create(ctx context.Context, c *types.Credential) (*types.Credential, error)
Update(ctx context.Context, c *types.Credential) (*types.Credential, error)
Delete(ctx context.Context, userID, credentialsID uint64) (err error)
}
userService interface {
FindByAny(ctx context.Context, identifier interface{}) (*types.User, error)
Update(context.Context, *types.User) (*types.User, error)
UploadAvatar(ctx context.Context, userID uint64, upload *multipart.FileHeader) (err error)
GenerateAvatar(ctx context.Context, userID uint64, bgColor string, initialColor string) (err error)
DeleteAvatar(ctx context.Context, userID uint64) (err error)
}
clientService interface {
Lookup(context.Context, interface{}) (*types.AuthClient, error)
Confirmed(context.Context, uint64) (types.AuthConfirmedClientSet, error)
Revoke(ctx context.Context, userID, clientID uint64) error
}
// @todo this should probably be a little more decoupled from the store and nicely named
tokenService interface {
SearchByUserID(ctx context.Context, userID uint64) (types.AuthOa2tokenSet, error)
DeleteByID(ctx context.Context, ID uint64) error
DeleteByUserID(ctx context.Context, userID uint64) error
}
templateExecutor interface {
ExecuteTemplate(io.Writer, string, interface{}, ...func(template *template.Template) *template.Template) error
}
oauth2Service interface {
GetRedirectURI(req *server.AuthorizeRequest, data map[string]interface{}) (string, error)
CheckResponseType(rt oauth2.ResponseType) bool
CheckCodeChallengeMethod(ccm oauth2.CodeChallengeMethod) bool
ValidationAuthorizeRequest(r *http.Request) (*server.AuthorizeRequest, error)
GetAuthorizeToken(ctx context.Context, req *server.AuthorizeRequest) (oauth2.TokenInfo, error)
GetAuthorizeData(rt oauth2.ResponseType, ti oauth2.TokenInfo) map[string]interface{}
HandleAuthorizeRequest(w http.ResponseWriter, r *http.Request) error
ValidationTokenRequest(r *http.Request) (oauth2.GrantType, *oauth2.TokenGenerateRequest, error)
CheckGrantType(gt oauth2.GrantType) bool
GetAccessToken(ctx context.Context, gt oauth2.GrantType, tgr *oauth2.TokenGenerateRequest) (oauth2.TokenInfo, error)
GetTokenData(ti oauth2.TokenInfo) map[string]interface{}
HandleTokenRequest(w http.ResponseWriter, r *http.Request) error
GetErrorData(err error) (map[string]interface{}, int, http.Header)
BearerAuth(r *http.Request) (string, bool)
ValidationBearerToken(r *http.Request) (oauth2.TokenInfo, error)
}
localeService interface {
NS(ctx context.Context, ns string) func(key string, rr ...string) string
T(ctx context.Context, ns, key string, rr ...string) string
HasLanguage(lang language.Tag) bool
LocalizedList(ctx context.Context) []*locale.Language
}
AuthHandlers struct {
Log *zap.Logger
Locale localeService
Templates templateExecutor
OAuth2 oauth2Service
SessionManager *request.SessionManager
AuthService authService
CredentialsService credentialsService
UserService userService
ClientService clientService
TokenService tokenService
DefaultClient *types.AuthClient
Opt options.AuthOpt
Settings *settings.Settings
SamlSPService *saml.SamlSPService
Attachment service.AttachmentService
}
handlerFn func(req *request.AuthReq) error
)
const (
TmplAuthorizedClients = "authorized-clients.html.tpl"
TmplChangePassword = "change-password.html.tpl"
TmplCreatePassword = "create-password.html.tpl"
TmplLogin = "login.html.tpl"
TmplLogout = "logout.html.tpl"
TmplOAuth2AuthorizeClient = "oauth2-authorize-client.html.tpl"
TmplRequestPasswordReset = "request-password-reset.html.tpl"
TmplPasswordResetRequested = "password-reset-requested.html.tpl"
TmplResetPassword = "reset-password.html.tpl"
TmplInvite = "invite.html.tpl"
TmplSecurity = "security.html.tpl"
TmplProfile = "profile.html.tpl"
TmplSessions = "sessions.html.tpl"
TmplSignup = "signup.html.tpl"
TmplPendingEmailConfirmation = "pending-email-confirmation.html.tpl"
TmplMfa = "mfa.html.tpl"
TmplMfaTotp = "mfa-totp.html.tpl"
TmplMfaTotpDisable = "mfa-totp-disable.html.tpl"
TmplInternalError = "error-internal.html.tpl"
// 1k of data per POST field is all we allow
maxPostValueLength = 2 << 9
// general limitation on number of fields
maxPostFields = 10
)
var (
// wrapper around time.Now() that will aid service testing
now = func() *time.Time {
c := time.Now()
return &c
}
)
func init() {
gob.Register(&types.User{})
gob.Register(&types.AuthClient{})
gob.Register([]request.Alert{})
gob.Register(url.Values{})
}
// handles auth request and prepares request struct with request, session and response helper
func (h *AuthHandlers) handle(fn handlerFn) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var (
req = &request.AuthReq{
Response: w,
Request: r,
Data: make(map[string]interface{}),
NewAlerts: make([]request.Alert, 0),
PrevAlerts: make([]request.Alert, 0),
Session: h.SessionManager.Get(r),
Locale: h.Locale,
}
)
h.Log.Debug(
"handling request",
zap.String("url", r.RequestURI),
zap.String("method", r.Method),
)
err := func() (err error) {
if err = r.ParseForm(); err != nil {
return
}
// Caching 32MB to memory, the rest to disk
err = r.ParseMultipartForm(32 << 20)
if err != nil && err != http.ErrNotMultipart {
return
}
if !validFormPost(r) {
req.Status = http.StatusRequestEntityTooLarge
return
}
req.Client = request.GetOauth2Client(req.Session)
req.AuthUser = request.GetAuthUser(req.Session)
// make sure user (identity) is part of the context
// so we can properly identify ourselves when interacting
// with services, and set user's preferred language
if req.AuthUser != nil && !req.AuthUser.PendingMFA() {
req.Request = req.Request.Clone(auth.SetIdentityToContext(
req.Context(),
auth.Authenticated(req.AuthUser.User.ID, req.AuthUser.User.Roles()...),
))
userPreferredLanguage := language.Make(req.AuthUser.User.Meta.PreferredLanguage)
// set user's preferred language
if h.Locale.HasLanguage(userPreferredLanguage) {
ctx := req.Request.Context()
ctx = locale.SetAcceptLanguageToContext(ctx, userPreferredLanguage)
ctx = locale.SetContentLanguageToContext(ctx, userPreferredLanguage)
req.Request = req.Request.WithContext(ctx)
}
}
// Alerts show for 1 session only!
req.PrevAlerts = req.PopAlerts()
if err = fn(req); err != nil {
h.Log.Error("error in handler", zap.Error(err))
}
if req.RedirectTo != "" && len(req.PrevAlerts) > 0 {
// redirect happened, so probably none noticed alerts
// lets push them at the end of new alerts
req.NewAlerts = append(req.NewAlerts, req.PrevAlerts...)
}
if len(req.NewAlerts) > 0 {
req.SetAlerts(req.NewAlerts...)
}
if err = sessions.Save(r, w); err != nil {
h.Log.Error("could not save session", zap.Error(err))
}
if req.Status == 0 {
switch {
case req.RedirectTo != "":
req.Status = http.StatusSeeOther
req.Template = ""
case req.Template != "":
req.Status = http.StatusOK
default:
req.Status = http.StatusInternalServerError
req.Template = TmplInternalError
}
}
return nil
}()
if err == nil {
if req.Status >= 300 && req.Status < 400 {
// redirect, nothing special to handle
http.Redirect(w, r, req.RedirectTo, req.Status)
return
}
if req.Status > 0 {
// in cases when something else already wrote the status
w.WriteHeader(req.Status)
}
}
var (
// translator template function
//
// we're adding these functions on every request on all templates
// because we need request's context to detect the language from!
ttf = func(t *template.Template) *template.Template {
return t.Funcs(map[string]interface{}{
"language": func() string { return locale.GetAcceptLanguageFromContext(req.Context()).String() },
"tr": func(key string, pp ...interface{}) template.HTML {
ss := make([]string, len(pp))
for i := range pp {
ss[i] = cast.ToString(pp[i])
}
return template.HTML(h.Locale.T(req.Context(), "auth", key, ss...))
},
})
}
)
// Handling just text/html response types from here on
//
// If handler does not wish to use the template leave/set it to "" (empty string)
if err == nil && req.Template != "" {
err = h.Templates.ExecuteTemplate(w,
req.Template,
h.enrichTmplData(req),
ttf,
)
h.Log.Debug("template executed", zap.String("name", req.Template), zap.Error(err))
}
if err != nil {
err = h.Templates.ExecuteTemplate(w, TmplInternalError, map[string]interface{}{
"error": err,
}, ttf)
if err == nil {
return
}
}
if err != nil {
h.Log.Error("unhandled error", zap.Error(err))
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
// Add alerts, settings, providers, csrf token, Bg
func (h *AuthHandlers) enrichTmplData(req *request.AuthReq) interface{} {
d := req.Data
d["theme"] = "light"
if req.AuthUser != nil {
maskEmail := service.CurrentSettings.Privacy.Mask.Email
maskName := service.CurrentSettings.Privacy.Mask.Name
service.CurrentSettings.Privacy.Mask.Email = false
service.CurrentSettings.Privacy.Mask.Name = false
// fetch current user with updated fields
user, err := h.UserService.FindByAny(req.Context(), req.AuthUser.User.ID)
service.CurrentSettings.Privacy.Mask.Email = maskEmail
service.CurrentSettings.Privacy.Mask.Name = maskName
// check if err is not nil and cater for MFA by checking if the error is not allowed to read
if err != nil && !errors.Is(err, service.UserErrNotAllowedToRead()) {
return err
}
d["user"] = user
d["theme"] = user.Meta.Theme
}
if req.Client != nil {
c := authClient{
ID: req.Client.ID,
Name: req.Client.Handle,
}
if req.Client.Meta != nil {
c.Name = req.Client.Meta.Name
c.Description = req.Client.Meta.Description
}
d["client"] = c
}
d[csrf.TemplateTag] = csrf.TemplateField(req.Request)
// In case we did not redirect, join previous alerts with new ones
d["alerts"] = append(req.PrevAlerts, req.NewAlerts...)
dSettings := *h.Settings
var pp = make([]provider, 0, len(dSettings.Providers))
if h.Settings.Saml.Enabled {
pp = append(pp, provider(saml.TemplateProvider(h.Settings.Saml.IDP.URL, h.Settings.Saml.Name)))
}
for _, p := range dSettings.Providers {
if _, err := goth.GetProvider(p.Handle); err != nil {
continue
}
// Skipping the ones we don't use for identity
if !p.HasUsage(types.ExternalProviderUsageIdentity) {
continue
}
out := provider{
Label: p.Label,
Handle: p.Handle,
Icon: p.Handle,
}
if strings.HasPrefix(out.Icon, external.OIDC_PROVIDER_PREFIX) {
out.Icon = "key"
}
pp = append(pp, out)
}
d["providers"] = pp
dSettings.Providers = nil
d["settings"] = dSettings
d["authBg"] = template.CSS(h.bgStylesData())
return d
}
func (h *AuthHandlers) bgStylesData() string {
if h.Settings.BackgroundUI.BackgroundImageSrcUrl == "" {
return fmt.Sprintf("background: url(%s/release-background.jpg) no-repeat top; %s",
GetLinks().Assets, h.Settings.BackgroundUI.Styles)
}
return fmt.Sprintf("background: url('%s') no-repeat top; %s",
h.Settings.BackgroundUI.BackgroundImageSrcUrl, h.Settings.BackgroundUI.Styles)
}
// Handle successful auth (on any factor)
func handleSuccessfulAuth(req *request.AuthReq) {
switch {
case req.AuthUser.PendingMFA():
req.RedirectTo = GetLinks().Mfa
case request.GetOAuth2AuthParams(req.Session) != nil:
// client authorization flow was paused, continue.
req.RedirectTo = GetLinks().OAuth2AuthorizeClient
default:
// Always go to profile
req.RedirectTo = GetLinks().Profile
}
}
// redirects anonymous users to login
func authOnly(fn handlerFn) handlerFn {
return func(req *request.AuthReq) error {
// these next few lines keep users away from the pages they should not see
// and redirect them to where they need to be
switch {
case req.AuthUser == nil || req.AuthUser.User == nil:
// not authenticated at all, move to login
req.RedirectTo = GetLinks().Login
case req.AuthUser.UnconfiguredTOTP():
// authenticated but need to configure MFA
req.RedirectTo = GetLinks().MfaTotpNewSecret
case req.AuthUser.PendingMFA():
// authenticated but MFA pending
req.RedirectTo = GetLinks().Mfa
default:
return fn(req)
}
return nil
}
}
func partAuthOnly(fn handlerFn) handlerFn {
return func(req *request.AuthReq) error {
if req.AuthUser == nil || req.AuthUser.User == nil {
req.RedirectTo = GetLinks().Login
return nil
} else {
return fn(req)
}
}
}
// redirects authenticated users to profile
func anonyOnly(fn handlerFn) handlerFn {
return func(req *request.AuthReq) error {
if req.AuthUser != nil && req.AuthUser.User != nil {
req.RedirectTo = GetLinks().Profile
return nil
} else {
return fn(req)
}
}
}
func translator(req *request.AuthReq, ns string) func(key string, rr ...string) string {
return req.Locale.NS(req.Context(), ns)
}
// general validation of posted data
//
// quite primitive for now but should be effective against out-of-bounds attacks
//
// in the future, more sophisticated validation might be needed
func validFormPost(r *http.Request) bool {
if len(r.Form) > maxPostFields {
// auth does not have any large forms
return false
}
// None of the values from the post fields should be longer than max length
for k, _ := range r.Form {
if len(r.Form[k]) > 1 {
// assuming only one value per field!
return false
}
if len(r.Form[k][0]) > maxPostValueLength {
return false
}
}
return true
}

View File

@@ -0,0 +1,122 @@
package handlers
import (
"regexp"
"strings"
)
type (
Links struct {
Profile,
Signup,
ConfirmEmail,
PendingEmailConfirmation,
Login,
Security,
ChangePassword,
CreatePassword,
RequestPasswordReset,
PasswordResetRequested,
ResetPassword,
AcceptInvite,
Sessions,
AuthorizedClients,
Logout,
OAuth2Authorize,
OAuth2AuthorizeClient,
OAuth2Token,
OAuth2Info,
OAuth2UserInfo,
OAuth2DefaultClient,
OAuth2PublicKeys,
Mfa,
MfaTotpNewSecret,
MfaTotpQRImage,
MfaTotpDisable,
External,
SamlInit,
SamlCallback,
SamlMetadata,
SamlLogout,
Base,
Assets,
AuthAssets string
}
)
var (
invalidLinkChars = regexp.MustCompile(`[^-A-Za-z0-9+&@#/%?=~_|!:,.;\\(\\)]`)
stripSchema = regexp.MustCompile(`(.*\/\/)`)
BasePath string = "/"
WebappBasePath string = "/"
)
func GetLinks() Links {
var b = strings.TrimSuffix(BasePath, "/") + "/"
return Links{
Profile: b + "auth",
Signup: b + "auth/signup",
ConfirmEmail: b + "auth/confirm-email",
PendingEmailConfirmation: b + "auth/pending-email-confirmation",
Login: b + "auth/login",
Security: b + "auth/security",
ChangePassword: b + "auth/change-password",
CreatePassword: b + "auth/create-password",
RequestPasswordReset: b + "auth/request-password-reset",
PasswordResetRequested: b + "auth/password-reset-requested",
ResetPassword: b + "auth/reset-password",
AcceptInvite: b + "auth/accept-invite",
Sessions: b + "auth/sessions",
AuthorizedClients: b + "auth/authorized-clients",
Logout: b + "auth/logout",
OAuth2Authorize: b + "auth/oauth2/authorize",
OAuth2AuthorizeClient: b + "auth/oauth2/authorize-client",
OAuth2Token: b + "auth/oauth2/token",
OAuth2Info: b + "auth/oauth2/info",
OAuth2UserInfo: b + "auth/oauth2/userinfo",
OAuth2DefaultClient: b + "auth/oauth2/default-client",
OAuth2PublicKeys: b + "auth/oauth2/public-keys",
Mfa: b + "auth/mfa",
MfaTotpNewSecret: b + "auth/mfa/totp/setup",
MfaTotpQRImage: b + "auth/mfa/totp/qr.png",
MfaTotpDisable: b + "auth/mfa/totp/disable",
External: b + "auth/external",
SamlInit: b + "auth/external/saml/init",
SamlCallback: b + "auth/external/saml/callback",
SamlMetadata: b + "auth/external/saml/metadata",
SamlLogout: b + "auth/external/saml/slo",
Assets: b + "assets",
AuthAssets: b + "auth/assets/public",
Base: b,
}
}
// trim base path
func tbp(s string) string {
s = strings.TrimPrefix(s, BasePath)
if !strings.HasPrefix(s, "/") {
s = "/" + s
}
return s
}
func sanitizeLink(l string) string {
return `//` + stripSchema.ReplaceAllString(invalidLinkChars.ReplaceAllString(l, ""), "")
}

View File

@@ -0,0 +1,41 @@
package handlers
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test_sanitizeLink(t *testing.T) {
type (
tt struct {
name string
link string
expect string
}
)
tcc := []tt{
{
name: `empty link`,
link: ``,
expect: `//`,
},
{
name: `Example URL with query`,
link: `https://example.url/query`,
expect: `//example.url/query`,
},
{
name: `URL with additional js`,
link: `javascript:window.alert('foobar')`,
expect: `//javascript:window.alert(foobar)`,
},
}
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
require.New(t).Equal(tc.expect, sanitizeLink(tc.link))
})
}
}

View File

@@ -0,0 +1,405 @@
package handlers
import (
"context"
"go.uber.org/zap"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"github.com/cortezaproject/corteza/server/auth/request"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/cortezaproject/corteza/server/pkg/locale"
"github.com/cortezaproject/corteza/server/pkg/options"
"github.com/cortezaproject/corteza/server/store"
"github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/go-oauth2/oauth2/v4"
"github.com/go-oauth2/oauth2/v4/server"
"github.com/gorilla/sessions"
)
type (
mockAuthService struct {
authService
store store.Storer
settings *types.AppSettings
notifications service.AuthNotificationService
providerValidator func(string) error
}
mockNotificationService struct {
settings *types.AppSettings
opt options.AuthOpt
}
mockSession struct {
Options *sessions.Options
get func(r *http.Request, name string) (*sessions.Session, error)
new func(r *http.Request, name string) (*sessions.Session, error)
save func(r *http.Request, w http.ResponseWriter, s *sessions.Session) error
}
oauth2ServiceMocked struct {
getRedirectURI func(req *server.AuthorizeRequest, data map[string]interface{}) (string, error)
checkResponseType func(rt oauth2.ResponseType) bool
checkCodeChallengeMethod func(ccm oauth2.CodeChallengeMethod) bool
validationAuthorizeRequest func(r *http.Request) (*server.AuthorizeRequest, error)
getAuthorizeToken func(ctx context.Context, req *server.AuthorizeRequest) (oauth2.TokenInfo, error)
getAuthorizeData func(rt oauth2.ResponseType, ti oauth2.TokenInfo) map[string]interface{}
handleAuthorizeRequest func(w http.ResponseWriter, r *http.Request) error
validationTokenRequest func(r *http.Request) (oauth2.GrantType, *oauth2.TokenGenerateRequest, error)
checkGrantType func(gt oauth2.GrantType) bool
getAccessToken func(ctx context.Context, gt oauth2.GrantType, tgr *oauth2.TokenGenerateRequest) (oauth2.TokenInfo, error)
getTokenData func(ti oauth2.TokenInfo) map[string]interface{}
handleTokenRequest func(w http.ResponseWriter, r *http.Request) error
getErrorData func(err error) (map[string]interface{}, int, http.Header)
bearerAuth func(r *http.Request) (string, bool)
validationBearerToken func(r *http.Request) (oauth2.TokenInfo, error)
}
testingExpect struct {
name string
payload interface{}
link string
err string
template string
alerts []request.Alert
userService userService
postForm url.Values
fn func(*settings.Settings)
}
userServiceMocked struct {
update func(context.Context, *types.User) (*types.User, error)
findByAny func(context.Context, interface{}) (*types.User, error)
uploadAvatar func(context.Context, uint64, *multipart.FileHeader) error
generateAvatar func(context.Context, uint64, string, string) error
deleteAvatar func(context.Context, uint64) error
}
authServiceMocked struct {
external func(context.Context, types.ExternalAuthUser) (u *types.User, err error)
internalSignUp func(context.Context, *types.User, string) (u *types.User, err error)
internalLogin func(context.Context, string, string) (u *types.User, err error)
setPassword func(context.Context, uint64, string) (err error)
changePassword func(context.Context, uint64, string, string) (err error)
createPassword func(context.Context, uint64, string) (err error)
validateEmailConfirmationToken func(context.Context, string) (user *types.User, err error)
validatePasswordResetToken func(context.Context, string) (user *types.User, err error)
validatePasswordCreateToken func(context.Context, string) (user *types.User, err error)
sendEmailAddressConfirmationToken func(context.Context, *types.User) (err error)
sendPasswordResetToken func(context.Context, string) (err error)
passwordSet func(context.Context, string) bool
getProviders func() types.ExternalAuthProviderSet
validateTOTP func(context.Context, string) (err error)
configureTOTP func(context.Context, string, string) (u *types.User, err error)
removeTOTP func(context.Context, uint64, string) (u *types.User, err error)
sendEmailOTP func(context.Context) (err error)
configureEmailOTP func(context.Context, uint64, bool) (u *types.User, err error)
validateEmailOTP func(context.Context, string) (err error)
sendInviteEmail func(context.Context, string) (err error)
validateEmailToken func(context.Context, string) (user *types.User, err error)
}
)
func (u userServiceMocked) UploadAvatar(ctx context.Context, userID uint64, upload *multipart.FileHeader) (err error) {
return u.uploadAvatar(ctx, userID, upload)
}
func (u userServiceMocked) GenerateAvatar(ctx context.Context, userID uint64, bgColor string, initialColor string) (err error) {
return u.generateAvatar(ctx, userID, bgColor, initialColor)
}
func (u userServiceMocked) DeleteAvatar(ctx context.Context, userID uint64) (err error) {
return u.deleteAvatar(ctx, userID)
}
// Mocking userService
func (u userServiceMocked) Update(ctx context.Context, user *types.User) (*types.User, error) {
return u.update(ctx, user)
}
func (u userServiceMocked) FindByAny(ctx context.Context, any interface{}) (*types.User, error) {
return u.findByAny(ctx, any)
}
// Mocking authService
func (s authServiceMocked) External(ctx context.Context, profile types.ExternalAuthUser) (u *types.User, err error) {
return s.external(ctx, profile)
}
func (s authServiceMocked) InternalSignUp(ctx context.Context, input *types.User, password string) (u *types.User, err error) {
return s.internalSignUp(ctx, input, password)
}
func (s authServiceMocked) InternalLogin(ctx context.Context, email string, password string) (u *types.User, err error) {
return s.internalLogin(ctx, email, password)
}
func (s authServiceMocked) SetPassword(ctx context.Context, userID uint64, password string) (err error) {
return s.setPassword(ctx, userID, password)
}
func (s authServiceMocked) ChangePassword(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) {
return s.changePassword(ctx, userID, oldPassword, newPassword)
}
func (s authServiceMocked) ValidateEmailConfirmationToken(ctx context.Context, token string) (user *types.User, err error) {
return s.validateEmailConfirmationToken(ctx, token)
}
func (s authServiceMocked) ValidatePasswordResetToken(ctx context.Context, token string) (user *types.User, err error) {
return s.validatePasswordResetToken(ctx, token)
}
func (s authServiceMocked) ValidatePasswordCreateToken(ctx context.Context, token string) (user *types.User, err error) {
return s.validatePasswordCreateToken(ctx, token)
}
func (s authServiceMocked) PasswordSet(ctx context.Context, email string) (is bool) {
return s.passwordSet(ctx, email)
}
func (s authServiceMocked) SendEmailAddressConfirmationToken(ctx context.Context, u *types.User) (err error) {
return s.sendEmailAddressConfirmationToken(ctx, u)
}
func (s authServiceMocked) SendPasswordResetToken(ctx context.Context, email string) (err error) {
return s.sendPasswordResetToken(ctx, email)
}
func (s authServiceMocked) GetProviders() types.ExternalAuthProviderSet {
return s.getProviders()
}
func (s authServiceMocked) ValidateTOTP(ctx context.Context, code string) (err error) {
return s.validateTOTP(ctx, code)
}
func (s authServiceMocked) ConfigureTOTP(ctx context.Context, secret string, code string) (u *types.User, err error) {
return s.configureTOTP(ctx, secret, code)
}
func (s authServiceMocked) RemoveTOTP(ctx context.Context, userID uint64, code string) (u *types.User, err error) {
return s.removeTOTP(ctx, userID, code)
}
func (s authServiceMocked) SendEmailOTP(ctx context.Context) (err error) {
return s.sendEmailOTP(ctx)
}
func (s authServiceMocked) ConfigureEmailOTP(ctx context.Context, userID uint64, enable bool) (u *types.User, err error) {
return s.configureEmailOTP(ctx, userID, enable)
}
func (s authServiceMocked) ValidateEmailOTP(ctx context.Context, code string) (err error) {
return s.validateEmailOTP(ctx, code)
}
func (s authServiceMocked) SendInviteEmail(ctx context.Context, email string) (err error) {
return s.sendInviteEmail(ctx, email)
}
func (s authServiceMocked) ValidateInviteEmailToken(ctx context.Context, token string) (user *types.User, err error) {
return s.validateEmailToken(ctx, token)
}
func (s authServiceMocked) LoadRoleMemberships(ctx context.Context, u *types.User) error {
// no-op for now
return nil
}
// Mocking oauth2Service
func (s *oauth2ServiceMocked) GetRedirectURI(req *server.AuthorizeRequest, data map[string]interface{}) (string, error) {
return s.GetRedirectURI(req, data)
}
func (s *oauth2ServiceMocked) CheckResponseType(rt oauth2.ResponseType) bool {
return s.checkResponseType(rt)
}
func (s *oauth2ServiceMocked) CheckCodeChallengeMethod(ccm oauth2.CodeChallengeMethod) bool {
return s.CheckCodeChallengeMethod(ccm)
}
func (s *oauth2ServiceMocked) ValidationAuthorizeRequest(r *http.Request) (*server.AuthorizeRequest, error) {
return s.validationAuthorizeRequest(r)
}
func (s *oauth2ServiceMocked) GetAuthorizeToken(ctx context.Context, req *server.AuthorizeRequest) (oauth2.TokenInfo, error) {
return s.getAuthorizeToken(ctx, req)
}
func (s *oauth2ServiceMocked) GetAuthorizeData(rt oauth2.ResponseType, ti oauth2.TokenInfo) map[string]interface{} {
return s.GetAuthorizeData(rt, ti)
}
func (s *oauth2ServiceMocked) HandleAuthorizeRequest(w http.ResponseWriter, r *http.Request) error {
return s.handleAuthorizeRequest(w, r)
}
func (s *oauth2ServiceMocked) ValidationTokenRequest(r *http.Request) (oauth2.GrantType, *oauth2.TokenGenerateRequest, error) {
return s.validationTokenRequest(r)
}
func (s *oauth2ServiceMocked) CheckGrantType(gt oauth2.GrantType) bool {
return s.checkGrantType(gt)
}
func (s *oauth2ServiceMocked) GetAccessToken(ctx context.Context, gt oauth2.GrantType, tgr *oauth2.TokenGenerateRequest) (oauth2.TokenInfo, error) {
return s.getAccessToken(ctx, gt, tgr)
}
func (s *oauth2ServiceMocked) GetTokenData(ti oauth2.TokenInfo) map[string]interface{} {
return s.getTokenData(ti)
}
func (s *oauth2ServiceMocked) HandleTokenRequest(w http.ResponseWriter, r *http.Request) error {
return s.handleTokenRequest(w, r)
}
func (s *oauth2ServiceMocked) GetErrorData(err error) (map[string]interface{}, int, http.Header) {
return s.getErrorData(err)
}
func (s *oauth2ServiceMocked) BearerAuth(r *http.Request) (string, bool) {
return s.bearerAuth(r)
}
func (s *oauth2ServiceMocked) ValidationBearerToken(r *http.Request) (oauth2.TokenInfo, error) {
return s.validationBearerToken(r)
}
// Mocking authService
func (ma mockAuthService) ValidatePasswordResetToken(ctx context.Context, token string) (*types.User, error) {
return &types.User{ID: 123}, nil
}
// Mocking authService
func (ma mockAuthService) ValidatePasswordCreateToken(ctx context.Context, token string) (*types.User, error) {
return &types.User{ID: 123}, nil
}
func (ma mockAuthService) SendEmailOTP(ctx context.Context) error {
return nil
}
func (ma mockAuthService) ValidateTOTP(ctx context.Context, code string) (err error) {
err = nil
return
}
func (ma mockAuthService) SendInviteEmail(ctx context.Context, email string) (err error) {
err = nil
return
}
func (ma mockAuthService) ValidateInviteEmailToken(ctx context.Context, token string) (*types.User, error) {
return &types.User{ID: 123}, nil
}
// Mocking notification service
func (m mockNotificationService) EmailConfirmation(ctx context.Context, emailAddress string, token string) error {
return nil
}
func (m mockNotificationService) PasswordReset(ctx context.Context, emailAddress string, token string) error {
return nil
}
func (m mockNotificationService) PasswordCreate(token string) (string, error) {
return "", nil
}
func (m mockNotificationService) EmailOTP(ctx context.Context, emailAddress string, code string) error {
return nil
}
func (m mockNotificationService) InviteEmail(ctx context.Context, emailAddress string, token string) error {
return nil
}
// Mocking gorilla session
func (ms mockSession) Get(r *http.Request, name string) (*sessions.Session, error) {
return ms.get(r, name)
}
func (ms mockSession) New(r *http.Request, name string) (*sessions.Session, error) {
return ms.new(r, name)
}
func (ms mockSession) Save(r *http.Request, w http.ResponseWriter, s *sessions.Session) error {
return ms.save(r, w, s)
}
// Helpers
func makeMockAuthService() *mockAuthService {
service.DefaultAuthNotification = mockNotificationService{
settings: service.CurrentSettings,
opt: options.AuthOpt{},
}
serviceAuth := service.Auth(service.AuthOptions{})
svc := mockAuthService{
authService: serviceAuth,
settings: service.CurrentSettings,
providerValidator: func(s string) error {
return nil
},
}
return &svc
}
func makeMockUser() *types.User {
u := &types.User{ID: 1, Username: "mock.user", Email: "mockuser@example.tld", Meta: &types.UserMeta{}}
u.Meta.SecurityPolicy.MFA.EnforcedEmailOTP = true
u.Meta.SecurityPolicy.MFA.EnforcedTOTP = false
return u
}
func prepareClientAuthReq(h *AuthHandlers, req *http.Request, user *types.User) *request.AuthReq {
s := &settings.Settings{}
s.MultiFactor.EmailOTP.Enabled = true
s.MultiFactor.EmailOTP.Enforced = true
s.MultiFactor.TOTP.Enabled = true
session := sessions.NewSession(&mockSession{
save: func(r *http.Request, w http.ResponseWriter, s *sessions.Session) error {
s.Values = make(map[interface{}]interface{})
return nil
},
}, "session")
authReq := &request.AuthReq{
Request: req,
Locale: h.Locale,
Session: session,
Response: httptest.NewRecorder(),
Data: make(map[string]interface{}),
}
if user != nil {
authReq.AuthUser = request.NewAuthUser(s, user, true)
}
return authReq
}
func prepareClientAuthService() *mockAuthService {
authService := makeMockAuthService()
return authService
}
func prepareClientAuthHandlers(authService authService, s *settings.Settings) *AuthHandlers {
return &AuthHandlers{
Log: zap.NewNop(),
Locale: locale.Static(),
AuthService: authService,
Settings: s,
}
}

View File

@@ -0,0 +1,148 @@
package handlers
import (
"net/http"
"github.com/cortezaproject/corteza/server/pkg/actionlog"
"github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/pkg/locale"
"github.com/go-chi/chi/v5"
"github.com/go-chi/httprate"
"github.com/gorilla/csrf"
)
func (h *AuthHandlers) MountHttpRoutes(r chi.Router) {
var (
l = GetLinks()
)
if h.Opt.DevelopmentMode {
r.Get("/auth/dev", h.handle(h.devView))
r.Get("/auth/dev/scenarios", h.devSceneView)
}
r.Handle("/auth/", http.RedirectHandler("/auth", http.StatusSeeOther))
r.Group(func(r chi.Router) {
r.Use(locale.DetectLanguage(locale.Global()))
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := actionlog.RequestOriginToContext(r.Context(), actionlog.RequestOrigin_Auth)
next.ServeHTTP(w, r.WithContext(ctx))
})
})
if h.Opt.RequestRateLimit > 0 {
r.Use(httprate.LimitByIP(h.Opt.RequestRateLimit, h.Opt.RequestRateWindowLength))
}
r.Use(auth.ExtraReqInfoMiddleware)
r.Group(func(r chi.Router) {
// all routes protected with CSRF:
if h.Opt.CsrfEnabled {
r.Use(csrf.Protect(
[]byte(h.Opt.CsrfSecret),
csrf.SameSite(csrf.SameSiteStrictMode),
csrf.Secure(h.Opt.SessionCookieSecure),
csrf.FieldName(h.Opt.CsrfFieldName),
csrf.CookieName(h.Opt.CsrfCookieName),
))
}
r.Get(tbp(l.Profile), h.handle(authOnly(h.profileForm)))
r.Post(tbp(l.Profile), h.handle(authOnly(h.profileProc)))
r.HandleFunc(tbp(l.Logout), h.handle(h.logoutProc))
r.Get(tbp(l.Sessions), h.handle(authOnly(h.sessionsView)))
r.Post(tbp(l.Sessions), h.handle(authOnly(h.sessionsProc)))
r.Get(tbp(l.AuthorizedClients), h.handle(authOnly(h.clientsView)))
r.Post(tbp(l.AuthorizedClients), h.handle(authOnly(h.clientsProc)))
r.Get(tbp(l.Signup), h.handle(h.onlyIfSignupEnabled(anonyOnly(h.signupForm))))
r.Post(tbp(l.Signup), h.handle(h.onlyIfSignupEnabled(anonyOnly(h.signupProc))))
r.Get(tbp(l.PendingEmailConfirmation), h.handle(h.pendingEmailConfirmation))
r.Get(tbp(l.ConfirmEmail), h.handle(h.confirmEmail))
r.Get(tbp(l.Login), h.handle(anonyOnly(h.loginForm)))
r.Post(tbp(l.Login), h.handle(h.onlyIfLocalEnabled(anonyOnly(h.loginProc))))
r.Get(tbp(l.Mfa), h.handle(h.mfaForm))
r.Post(tbp(l.Mfa), h.handle(h.mfaProc))
r.Get(tbp(l.RequestPasswordReset), h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.requestPasswordResetForm))))
r.Post(tbp(l.RequestPasswordReset), h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.requestPasswordResetProc))))
r.Get(tbp(l.PasswordResetRequested), h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.passwordResetRequested))))
r.Get(tbp(l.ResetPassword), h.handle(h.onlyIfPasswordResetEnabled(h.resetPasswordForm)))
r.Post(tbp(l.ResetPassword), h.handle(h.onlyIfPasswordResetEnabled(authOnly(h.resetPasswordProc))))
r.Get(tbp(l.AcceptInvite), h.handle(h.acceptInviteForm))
r.Post(tbp(l.AcceptInvite), h.handle(h.acceptInviteProc))
r.Get(tbp(l.Security), h.handle(authOnly(h.securityForm)))
r.Post(tbp(l.Security), h.handle(authOnly(h.securityProc)))
r.Get(tbp(l.ChangePassword), h.handle(h.onlyIfLocalEnabled(authOnly(h.changePasswordForm))))
r.Post(tbp(l.ChangePassword), h.handle(h.onlyIfLocalEnabled(authOnly(h.changePasswordProc))))
r.Get(tbp(l.CreatePassword), h.handle(h.onlyIfPasswordCreateEnabled(h.createPasswordForm)))
r.Post(tbp(l.CreatePassword), h.handle(h.onlyIfPasswordCreateEnabled(h.createPasswordProc)))
r.Get(tbp(l.MfaTotpNewSecret), h.handle(partAuthOnly(h.mfaTotpConfigForm)))
r.Post(tbp(l.MfaTotpNewSecret), h.handle(partAuthOnly(h.mfaTotpConfigProc)))
r.Get(tbp(l.MfaTotpQRImage), h.handle(partAuthOnly(h.mfaTotpConfigQR)))
r.Get(tbp(l.MfaTotpDisable), h.handle(authOnly(h.mfaTotpDisableForm)))
r.Post(tbp(l.MfaTotpDisable), h.handle(authOnly(h.mfaTotpDisableProc)))
})
r.Group(func(r chi.Router) {
// OAuth2 routes
r.HandleFunc(tbp(l.OAuth2Authorize), h.handle(h.oauth2Authorize))
r.Get(tbp(l.OAuth2AuthorizeClient), h.handle(authOnly(h.oauth2AuthorizeClient)))
r.Post(tbp(l.OAuth2AuthorizeClient), h.handle(authOnly(h.oauth2AuthorizeClientProc)))
r.Get(tbp(l.OAuth2DefaultClient), h.handle(h.oauth2authorizeDefaultClient))
r.Post(tbp(l.OAuth2DefaultClient), h.handle(h.oauth2authorizeDefaultClientProc))
})
// userinfo is authorized with an access token, not with a session; CSRF does not apply
r.Get(tbp(l.OAuth2UserInfo), h.oauth2Userinfo)
r.Post(tbp(l.OAuth2UserInfo), h.oauth2Userinfo)
// Wrapping SAML structs so we assure that fresh ones are always used in case
// of settings changes.
//
// @todo refactor this with a wrapping struct to handle serve HTTPS and pass
// calls to internal SAML service.
r.Group(func(r chi.Router) {
r.HandleFunc(tbp(l.SamlMetadata), func(rw http.ResponseWriter, r *http.Request) {
if h.SamlSPService == nil || !h.SamlSPService.Enabled {
rw.WriteHeader(http.StatusServiceUnavailable)
return
}
h.SamlSPService.ServeHTTP(rw, r)
})
r.HandleFunc(tbp(l.SamlCallback), func(rw http.ResponseWriter, r *http.Request) {
if h.SamlSPService == nil || !h.SamlSPService.Enabled {
rw.WriteHeader(http.StatusServiceUnavailable)
return
}
h.SamlSPService.ServeHTTP(rw, r)
})
r.HandleFunc(tbp(l.SamlInit), func(rw http.ResponseWriter, r *http.Request) {
h.samlInit(rw, r)
})
})
r.Route(tbp(l.External)+"/{provider}", func(r chi.Router) {
// External provider
r.Get("/", h.externalInit)
r.Get("/callback", h.externalCallback)
})
r.HandleFunc("/auth/oauth2/token", h.handle(h.oauth2Token))
r.HandleFunc("/auth/oauth2/info", h.oauth2Info)
r.HandleFunc("/auth/oauth2/public-keys", h.oauth2PublicKeys)
})
}