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
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:
110
server/auth/handlers/handle_authorized-clients.go
Normal file
110
server/auth/handlers/handle_authorized-clients.go
Normal 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
|
||||
}
|
||||
54
server/auth/handlers/handle_change-password.go
Normal file
54
server/auth/handlers/handle_change-password.go
Normal 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
|
||||
}
|
||||
}
|
||||
149
server/auth/handlers/handle_change-password_test.go
Normal file
149
server/auth/handlers/handle_change-password_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
117
server/auth/handlers/handle_create-password.go
Normal file
117
server/auth/handlers/handle_create-password.go
Normal 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"),
|
||||
})
|
||||
}
|
||||
181
server/auth/handlers/handle_create-password_test.go
Normal file
181
server/auth/handlers/handle_create-password_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
123
server/auth/handlers/handle_dev.go
Normal file
123
server/auth/handlers/handle_dev.go
Normal 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
|
||||
})
|
||||
}
|
||||
232
server/auth/handlers/handle_external.go
Normal file
232
server/auth/handlers/handle_external.go
Normal 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)
|
||||
}
|
||||
79
server/auth/handlers/handle_invite.go
Normal file
79
server/auth/handlers/handle_invite.go
Normal 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
|
||||
}
|
||||
}
|
||||
163
server/auth/handlers/handle_login.go
Normal file
163
server/auth/handlers/handle_login.go
Normal 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"))
|
||||
}
|
||||
214
server/auth/handlers/handle_login_test.go
Normal file
214
server/auth/handlers/handle_login_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
32
server/auth/handlers/handle_logout.go
Normal file
32
server/auth/handlers/handle_logout.go
Normal 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
|
||||
}
|
||||
48
server/auth/handlers/handle_logout_test.go
Normal file
48
server/auth/handlers/handle_logout_test.go
Normal 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)
|
||||
}
|
||||
87
server/auth/handlers/handle_mfa.go
Normal file
87
server/auth/handlers/handle_mfa.go
Normal 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
|
||||
}
|
||||
212
server/auth/handlers/handle_mfa_test.go
Normal file
212
server/auth/handlers/handle_mfa_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
211
server/auth/handlers/handle_mfa_totp.go
Normal file
211
server/auth/handlers/handle_mfa_totp.go
Normal 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
|
||||
}
|
||||
}
|
||||
708
server/auth/handlers/handle_oauth2.go
Normal file
708
server/auth/handlers/handle_oauth2.go
Normal 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)
|
||||
}
|
||||
389
server/auth/handlers/handle_oauth2_test.go
Normal file
389
server/auth/handlers/handle_oauth2_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
134
server/auth/handlers/handle_password-reset.go
Normal file
134
server/auth/handlers/handle_password-reset.go
Normal 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"),
|
||||
})
|
||||
}
|
||||
250
server/auth/handlers/handle_password-reset_test.go
Normal file
250
server/auth/handlers/handle_password-reset_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
180
server/auth/handlers/handle_profile.go
Normal file
180
server/auth/handlers/handle_profile.go
Normal 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), "/"))
|
||||
}
|
||||
296
server/auth/handlers/handle_profile_test.go
Normal file
296
server/auth/handlers/handle_profile_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
35
server/auth/handlers/handle_saml.go
Normal file
35
server/auth/handlers/handle_saml.go
Normal 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)
|
||||
}
|
||||
}
|
||||
69
server/auth/handlers/handle_security.go
Normal file
69
server/auth/handlers/handle_security.go
Normal 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
|
||||
}
|
||||
159
server/auth/handlers/handle_security_test.go
Normal file
159
server/auth/handlers/handle_security_test.go
Normal 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)
|
||||
}
|
||||
136
server/auth/handlers/handle_sessions.go
Normal file
136
server/auth/handlers/handle_sessions.go
Normal 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
|
||||
}
|
||||
190
server/auth/handlers/handle_signup.go
Normal file
190
server/auth/handlers/handle_signup.go
Normal 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"),
|
||||
})
|
||||
}
|
||||
241
server/auth/handlers/handle_signup_test.go
Normal file
241
server/auth/handlers/handle_signup_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
537
server/auth/handlers/handler.go
Normal file
537
server/auth/handlers/handler.go
Normal 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
|
||||
}
|
||||
122
server/auth/handlers/links.go
Normal file
122
server/auth/handlers/links.go
Normal 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, ""), "")
|
||||
}
|
||||
41
server/auth/handlers/links_test.go
Normal file
41
server/auth/handlers/links_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
405
server/auth/handlers/mock_test.go
Normal file
405
server/auth/handlers/mock_test.go
Normal 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,
|
||||
}
|
||||
}
|
||||
148
server/auth/handlers/routes.go
Normal file
148
server/auth/handlers/routes.go
Normal 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user