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

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

17
server/auth/README.adoc Normal file
View File

@@ -0,0 +1,17 @@
= Corteza auth
.Main responsibilities:
- Sign-up protocol
- Local login via OAuth2 protocol
- Password set, reset protocol
- Email authentication protocol
- Session management
- Identity federation (login via external providers)
- Support OAuth2 flows
== Development
=== Templates
All (golang) templates can be found under `assets/templates`.
You can also find `scenarios.yaml` with list of all templates and scenaris with data that renders auth screens in all possible situations.

View File

@@ -0,0 +1,39 @@
@font-face {
font-family: 'Poppins-Bold';
src: url('./fonts/poppins/Poppins-Bold.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Medium';
src: url('./fonts/poppins/Poppins-Medium.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Regular';
src: url('./fonts/poppins/Poppins-Regular.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-SemiBold';
src: url('./fonts/poppins/Poppins-SemiBold.ttf') format('truetype');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Poppins-Italic';
src: url('./fonts/poppins/Poppins-Italic.ttf') format('truetype');
font-weight: 100 900;
font-style: italic;
font-display: swap;
}

View File

@@ -0,0 +1,18 @@
$(function () {
$('.toast').toast('show');
$('form').not('do-not-disable-buttons-on-submit').on('submit', function() {
let form = this
setTimeout(function() {
$('button, input[type=submit]', form)
.not('do-not-disable-on-submit')
.attr('disabled', true)
}, 50)
})
$('input.mfa-code-mask').mask('000 000')
$('#avatar').change(function () {
$('form').submit();
})
})

View File

@@ -0,0 +1,52 @@
html {
height: 100%;
}
body {
min-height: 100%;
display: grid;
grid-template-rows: auto;
overflow: auto;
}
label {
color: var(--primary);
}
.logo {
max-height: 45px;
max-width: 25%;
}
.version {
bottom: 0;
right: 0;
}
.header {
background-color: var(--light) !important;
}
/* Navigation */
.nav-item.active {
border-bottom: 4px solid var(--primary);
}
.login-title {
font-size: 25px;
}
.auth {
width: 100%;
max-width: 650px;
margin: auto;
padding: 0 20px;
}
.footer {
margin-top: auto;
}
.bi::before {
vertical-align: middle;
}

View File

@@ -0,0 +1,44 @@
{{ template "inc_header.html.tpl" set . "activeNav" "clients" }}
<div class="card-body p-0">
<form
method="POST"
action="{{ links.AuthorizedClients }}"
class="clearfix p-3"
>
{{ .csrfField }}
{{ range .authorizedClients }}
<div class="mb-2 pb-2 border-bottom">
<div class="text-primary font-weight-bold">{{ .Name }}</div>
<div>
{{ tr "authorized-clients.template.list.authorized-on" }}
<time
datetime="{{ .ConfirmedAt | date "2006-01-02T15:04:05Z07:00" }}"
>
{{ .ConfirmedAt | date "Mon, 02 Jan 2006 15:04 MST" }}
</time>
</div>
<button
data-test-id="button-revoke-access"
type="submit"
name="revoke"
value="{{ .ID }}"
class="btn btn-sm btn-danger my-2"
>
{{ tr "authorized-clients.template.list.buttons.revoke" }}
</button>
</div>
{{ end }}
</form>
{{ if not .authorizedClients}}
<div
data-test-id="text-empty-list"
class="text-center m-3 mb-3"
>
<i>{{ tr "authorized-clients.template.list.empty" }}</i>
</div>
{{ end }}
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,75 @@
{{ template "inc_header.html.tpl" set . "activeNav" "security" }}
<div class="card-body p-0">
<h4 class="mb-0 p-3 border-bottom">{{ tr "change-password.template.title" }}</h4>
<form
method="POST"
action="{{ links.ChangePassword }}"
class="p-3"
>
{{ .csrfField }}
{{ if .form.error }}
<div
data-test-id="error"
class="text-danger font-weight-bold mb-3"
role="alert"
>
{{ .form.error }}
</div>
{{ end }}
<div class="mb-3">
<label>
{{ tr "change-password.template.form.email.label" }}
</label>
<input
data-test-id="input-email"
type="email"
class="form-control"
name="email"
readonly
placeholder="{{ tr "change-password.template.form.email.placeholder" }}"
value="{{ .user.Email }}"
aria-label="{{ tr "change-password.template.form.email.label" }}"
>
</div>
<div class="mb-3">
<label>
{{ tr "change-password.template.form.old-password.label" }}
</label>
<input
data-test-id="input-old-password"
type="password"
required
class="form-control"
name="oldPassword"
autocomplete="current-password"
placeholder="{{ tr "change-password.template.form.old-password.placeholder" }}"
aria-label="{{ tr "change-password.template.form.old-password.label" }}"
>
</div>
<div class="mb-3">
<label>
{{ tr "change-password.template.form.new-password.label" }}
</label>
<input
data-test-id="input-new-password"
type="password"
required
class="form-control"
name="newPassword"
autocomplete="new-password"
placeholder="{{ tr "change-password.template.form.new-password.placeholder" }}"
aria-label="{{ tr "change-password.template.form.new-password.label" }}"
>
</div>
<div class="text-right">
<button
data-test-id="button-change-password"
class="btn btn-primary btn-block btn-lg"
type="submit"
>
{{ tr "change-password.template.form.button.change-password" }}
</button>
</div>
</form>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,59 @@
{{ template "inc_header.html.tpl" set . "activeNav" "security" }}
<div class="card-body p-0">
<h4 class="mb-0 p-3 border-bottom">{{ tr "create-password.template.title" }}</h4>
<form
method="POST"
action="{{ links.CreatePassword }}"
class="p-3"
>
{{ .csrfField }}
{{ if .form.error }}
<div
data-test-id="error"
class="text-danger font-weight-bold mb-3"
role="alert"
>
{{ .form.error }}
</div>
{{ end }}
<div class="mb-3">
<label>
{{ tr "create-password.template.form.email.label" }}
</label>
<input
data-test-id="input-email"
type="email"
class="form-control"
name="email"
readonly
value="{{ .user.Email }}"
aria-label="{{ tr "create-password.template.form.email.label" }}"
>
</div>
<div class="mb-3">
<label>
{{ tr "create-password.template.form.password.label" }}
</label>
<input
data-test-id="input-password"
type="password"
required
class="form-control"
name="password"
autocomplete="password"
placeholder="{{ tr "create-password.template.form.password.placeholder" }}"
aria-label="{{ tr "create-password.template.form.password.label" }}"
>
</div>
<div class="text-right">
<button
data-test-id="button-create-password"
class="btn btn-primary btn-block btn-lg"
type="submit"
>
{{ tr "create-password.template.form.button.create-password" }}
</button>
</div>
</form>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,8 @@
{{ template "inc_header.html.tpl" . }}
<div class="card-body p-0">
<h4 class="mb-0 p-3 border-bottom">{{ tr "error-internal.template.title" }}</h4>
<div class="text-danger mb-4 font-weight-bold p-3" role="alert">
{{ .error }}
</div>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,12 @@
</main>
{{ template "inc_toasts.html.tpl" .alerts }}
<footer class="d-flex align-items-end justify-content-center text-white py-4">
{{ tr "inc_footer.code-link" }}
<a data-test-id="link-github" href="https://github.com/cortezaproject/" target="_blank" class="text-white ml-1">GitHub</a>
</footer>
</body>
<script src="https://code.jquery.com/jquery-3.5.1.slim.min.js" integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-Piv4xVNRyMGpqkS2by6br4gNJ7DXjqk09RmUpJ8jgGtD7zP9yug3goQfGII0yAns" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.mask/1.14.16/jquery.mask.js" integrity="sha512-0XDfGxFliYJPFrideYOoxdgNIvrwGTLnmK20xZbCAvPfLGQMzHUsaqZK8ZoH+luXGRxTrS46+Aq400nCnAT0/w==" crossorigin="anonymous"></script>
<script src="{{ links.AuthAssets }}/script.js?{{ buildtime }}"></script>
</html>

View File

@@ -0,0 +1,56 @@
<!doctype html>
<html data-color-mode="{{ .theme }}" lang="{{ language }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap icons -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.3.0/font/bootstrap-icons.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="icon" id="favicon" href="{{ links.Assets }}/icon.svg">
<!-- Fonts -->
<link href="{{ links.AuthAssets }}/fonts.css" rel="stylesheet">
<!-- Custom CSS -->
<link href="/custom.css" rel="stylesheet">
<link href="{{ links.AuthAssets }}/style.css?{{ buildtime }}" rel="stylesheet">
<title>Corteza</title>
<style>
body {
font-size: 1rem !important;
{{ .authBg }}
background-size: cover;
background-attachment: fixed;
}
</style>
</head>
<body>
<header>
{{ if .user }}
<div class="d-flex justify-content-end align-items-center text-white m-2">
<a class="text-white font-weight-bold" href="{{ links.Base }}">
{{ tr "inc_header.menu" }}
</a>
<span class="mx-2">
|
</span>
{{ tr "inc_header.logged-in-as" }}
<a data-test-id="link-redirect-to-profile" class="font-weight-bold text-white mx-2"
href="{{ links.Profile }}">{{ coalesce .user.Name .user.Handle .user.Email }}
</a>
|
<a data-test-id="link-logout" class="font-weight-bold text-white ml-2" href="{{ links.Logout }}">
{{ tr "inc_header.logout" }}
</a>
</div>
{{ end }}
</header>
<main class="auth mt-sm-5">
<div class="tabs card">
{{ template "inc_nav.html.tpl" . }}

View File

@@ -0,0 +1,56 @@
<div class="header card-header border-0 p-0">
<div class="text-center w-100 my-2 my-sm-4">
<a href="{{ links.Profile }}">
<img
data-test-id="img-corteza-logo"
class="logo"
alt="Company logo"
src="{{ links.Assets }}/logo.svg"
>
</a>
</div>
{{ $activeNav := default "" .activeNav }}
{{ if not .hideNav }}
{{ if .user }}
<ul class="nav d-flex justify-content-around">
<li class="nav-item {{ if eq $activeNav "profile" }}active{{ end }}">
<a
data-test-id="link-tab-profile"
class="nav-link"
href="{{ links.Profile }}"
>
{{ tr "inc_nav.template.class.your-profile" }}
</a>
</li>
<li class="nav-item {{ if eq $activeNav "security" }}active{{ end }}">
<a
data-test-id="link-tab-security"
class="nav-link"
href="{{ links.Security }}"
>
{{ tr "inc_nav.template.class.security" }}
</a>
</li>
<li class="nav-item {{ if eq $activeNav "sessions" }}active{{ end }}">
<a
data-test-id="link-tab-login-session"
class="nav-link"
href="{{ links.Sessions }}"
>
{{ tr "inc_nav.template.class.login-session" }}
</a>
</li>
<li class="nav-item {{ if eq $activeNav "clients" }}active{{ end }}">
<a
data-test-id="link-tab-authorized-clients"
class="nav-link"
href="{{ links.AuthorizedClients }}"
>
{{ tr "inc_nav.template.class.authorized-clients" }}
</a>
</li>
</ul>
{{ end }}
{{ end }}
</div>

View File

@@ -0,0 +1,12 @@
<div class="position-fixed bottom-0 right-0 p-3 mb-2" style="z-index: 5; right: 0; bottom: 0;">
{{ range . }}
<div class="toast align-items-center bg-dark text-white" role="alert" aria-live="polite" aria-atomic="true" data-delay="5000" style="min-width:200px;">
<div class="d-flex align-items-center toast-body border-top border-{{ .Type }}">
{{ .Text | html }}
<button type="button" class="ml-auto text-white btn p-0" data-dismiss="toast" aria-label="Close">
<i class="bi bi-x"></i>
</button>
</div>
</div>
{{ end }}
</div>

View File

@@ -0,0 +1,56 @@
{{ template "inc_header.html.tpl" . }}
<div class="card-body p-0">
<h4 class="mb-0 p-3 border-bottom">{{ tr "invite.template.title" }}</h4>
<form
method="POST"
action="{{ links.AcceptInvite }}"
class="p-3"
>
{{ .csrfField }}
{{ if .form.error }}
<div class="text-danger font-weight-bold p-3" role="alert">
{{ .form.error }}
</div>
{{ end }}
<div class="mb-3">
<label>
{{ tr "invite.template.form.email.label" }}
</label>
<input
data-test-id="input-email"
type="email"
class="form-control"
name="email"
readonly
placeholder="{{ tr "invite.template.form.email.placeholder" }}"
value="{{ .user.Email }}"
aria-label="{{ tr "invite.template.form.emaillabel" }}"
>
</div>
<div class="mb-3">
<label>
{{ tr "invite.template.form.new-password.label" }}
</label>
<input
data-test-id="input-new-password"
type="password"
required
class="form-control"
name="password"
autocomplete="new-password"
placeholder="{{ tr "invite.template.form.new-password.placeholder" }}"
aria-label="{{ tr "invite.template.form.new-passwordlabel" }}"
>
</div>
<div class="text-right">
<button
data-test-id="button-change-password"
class="btn btn-primary btn-block btn-lg"
type="submit"
>
{{ tr "invite.template.form.buttons.password" }}
</button>
</div>
</form>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,123 @@
{{ template "inc_header.html.tpl" . }}
<div class="card-body p-0">
<h4 class="login-title mb-0 p-3 border-bottom">{{ tr "login.template.title" }}</h4>
{{ if .settings.LocalEnabled }}
<form
method="POST"
action="{{ links.Login }}"
class="p-3"
>
{{ .csrfField }}
{{ if .form.error }}
<div
data-test-id="error"
class="text-danger mb-4 font-weight-bold"
role="alert"
>
{{ .form.error }}
</div>
{{ end }}
<div class="mb-3">
<label>
{{ tr "login.template.form.email.label" }}
</label>
<input
data-test-id="input-email"
type="email"
class="form-control"
name="email"
required
placeholder="{{ tr "login.template.form.email.placeholder" }}"
value="{{ if .form }}{{ .form.email }}{{ end }}"
autocomplete="username"
aria-label="{{ tr "login.template.form.email.label" }}">
</div>
{{ if not .form.splitCredentialsCheck }}
<div class="mb-3">
<label>
{{ tr "login.template.form.password.label" }}
</label>
<input
data-test-id="input-password"
type="password"
required
class="form-control"
name="password"
placeholder="{{ tr "login.template.form.password.placeholder" }}"
autocomplete="current-password"
aria-label="{{ tr "login.template.form.password.label" }}">
</div>
<div class="row">
<div class="col text-right">
{{ if .enableRememberMe }}
<button
data-test-id="button-login-and-remember"
class="btn btn-primary btn-block btn-lg"
name="keep-session"
value="true"
type="submit"
>
{{ tr "login.template.form.button.login-and-remember" }}
</button>
{{ end }}
<button
data-test-id="button-login"
class="btn btn-light btn-block btn-lg"
type="submit"
>
{{ tr "login.template.form.button.login" }}
</button>
</div>
</div>
{{ else }}
<div class="row">
<div class="col text-right">
<button
data-test-id="button-continue"
class="btn btn-primary btn-block btn-lg"
name="keep-session"
value="true"
type="submit"
>
{{ tr "login.template.form.button.continue" }}
</button>
</div>
</div>
{{ end }}
</form>
<div class="row text-center px-3 pb-3">
{{ if .settings.PasswordResetEnabled }}
<div class="col cols-6">
<a
data-test-id="link-request-password-reset"
href="{{ links.RequestPasswordReset }}"
>
{{ tr "login.template.links.request-password-reset" }}
</a>
</div>
{{ end }}
{{ if .settings.SignupEnabled }}
<div class="col cols-6">
<a
data-test-id="link-signup"
href="{{ links.Signup }}"
>
{{ tr "login.template.links.signup" }}
</a>
</div>
{{ end }}
</div>
{{ end }}
{{ if .settings.ExternalEnabled }}
<div class="px-3 pb-3">
{{ range .providers }}
<a href="{{ links.External }}/{{ .Handle }}" class="btn btn-light btn-block btn-lg mb-2 mt-1 text-dark">
<i class="bi bi-{{ .Icon }} mr-1"></i>
{{ tr "login.template.links.external.login-with" "idp" (coalesce .Label .Handle) }}
</a>
{{ end }}
</div>
{{ end }}
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,24 @@
{{ template "inc_header.html.tpl" . }}
<div class="card-body">
<form>
{{ if .form.error }}
<div
data-test-id="error"
class="text-danger font-weight-bold"
role="alert"
>
{{ .form.error }}
</div>
{{ else }}
<div class="text-dark font-weight-bold" role="primary">
{{ tr "logout.template.log-out" }}
</div>
{{ end }}
<div class="text-center my-3">
{{ tr "logout.template.log-in" "link" .link }}
</div>
</form>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,44 @@
{{ template "inc_header.html.tpl" set . "hideNav" true }}
<div class="card-body p-0">
<h4 class="mb-0 p-3 border-bottom">{{ tr "mfa-totp-disable.template.title" }}</h4>
<form
class="p-3"
method="POST"
action="{{ links.MfaTotpDisable }}"
>
{{ tr "mfa-totp-disable.template.instructions" }}
{{ if .form.error }}
<div class="pt-3 text-danger font-weight-bold" role="alert">
{{ .form.error }}
</div>
{{ end }}
{{ .csrfField }}
<div class="input-group my-3">
<input
type="text"
required
class="form-control lg text-center mfa-code-mask"
name="code"
maxlength="6"
minlength="6"
aria-required="true"
placeholder="000 000"
autocomplete="off"
style="letter-spacing:5px;font-size:20px;"
aria-label="Code">
</div>
<button
class="btn btn-primary btn-block btn-lg"
name="keep-session"
value="true"
type="submit"
>
{{ tr "mfa-totp-disable.template.button.remove" }}
</button>
</form>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,83 @@
{{ template "inc_header.html.tpl" set . "hideNav" true }}
<div class="card-body p-0">
<h4 class="mb-0 p-3 border-bottom">{{ tr "mfa-totp.template.title" }}</h4>
{{ if .enforced }}
<p
data-test-id="p-enforced-TOTP"
class="p-3 text-danger mb-0 font-weight-bold"
>
{{ tr "mfa-totp.template.enforced" }}
</p>
{{ end }}
<div
data-test-id="div-configure-TOTP"
class="container p-3 m-0"
>
<div class="row">
<div class="col-12 col-sm-6">
<p>
{{ tr "mfa-totp.template.instructions" }}
</p>
<ul>
<li>
{{ tr "mfa-totp.template.lastpass" "lastpass" "https://lastpass.com/auth/" }}
</li>
<li>
{{ tr "mfa-totp.template.gauth" "android" "https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" "iphone" "https://apps.apple.com/us/app/google-authenticator/id388497605" }}
</li>
<li>
{{ tr "mfa-totp.template.authy" "link" "https://authy.com" }}
</li>
</ul>
</div>
<div class="col-12 col-sm-6 p-0 mb-3">
<img class="px-4 w-100" style="margin-top: -1rem; margin-bottom: -1rem;" src="{{ if .devQRImage }}{{ .devQRImage }}{{ else }}{{ links.MfaTotpQRImage }}{{ end }}" />
<h5 class="px-4 text-center mb-3">{{ .secret }}</h5>
<form
class="px-3"
method="POST"
action="{{ links.MfaTotpNewSecret }}"
>
{{ tr "mfa-totp.template.form.title" }}
{{ if .form.error }}
<div class="alert alert-danger" role="alert">
{{ .form.error }}
</div>
{{ end }}
{{ .csrfField }}
<div class="input-group my-3">
<input
type="text"
required
class="form-control lg text-center mfa-code-mask"
name="code"
maxlength="6"
minlength="6"
aria-required="true"
placeholder="000 000"
autocomplete="off"
style="letter-spacing:5px;font-size:20px;"
aria-label="Code">
</div>
<button
data-test-id="button-submit"
class="btn btn-primary btn-block btn-lg"
name="keep-session"
value="true"
type="submit"
>
{{ tr "mfa-totp.template.form.button" }}
</button>
</form>
</div>
</div>
</div>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,106 @@
{{ template "inc_header.html.tpl" set . "hideNav" true }}
<div class="card-body p-0 mb-2">
<h4 class="mb-0 p-3 border-bottom">{{ tr "mfa.template.title" }}</h4>
{{ if .emailOtpPending }}
<form
class="p-3"
method="POST"
action="{{ links.Mfa }}"
>
<p>{{ tr "mfa.template.email.instructions" }}</p>
{{ if .form.emailOtpError }}
<div class="text-danger my-4 font-weight-bold" role="alert">
{{ .form.emailOtpError }}
</div>
{{ end }}
{{ .csrfField }}
<div class="input-group my-3">
<input
type="text"
required
class="form-control text-center mfa-code-mask"
name="code"
maxlength="6"
minlength="6"
aria-required="true"
placeholder="000 000"
autocomplete="off"
style="letter-spacing:5px;font-size:20px;"
aria-label="{{ tr "mfa.template.email.code" }}">
</div>
<button
class="btn btn-primary btn-block btn-lg"
name="action"
value="verifyEmailOtp"
type="submit"
>
{{ tr "mfa.template.email.verify" }}
</button>
<a
href="{{ links.Mfa }}?action=resendEmailOtp"
class="btn btn-light btn-block btn-lg text-dark"
name="action"
value="resendEmailOtp"
>
{{ tr "mfa.template.email.resend" }}
</a>
</form>
{{ else if not .emailOtpDisabled }}
<p class="p-3 mb-0">
<i class="bi bi-check-circle text-success h5 mr-1"></i> {{ tr "mfa.template.email.confirmed" }}
</p>
{{ end }}
{{ if .totpPending }}
<form
class="p-3"
method="POST"
action="{{ links.Mfa }}"
>
<p>{{ tr "mfa.template.totp.instructions" }}</p>
{{ if .form.totpError }}
<div class="alert alert-danger" role="alert">
{{ .form.totpError }}
</div>
{{ end }}
{{ .csrfField }}
<div class="input-group my-3">
<input
type="text"
required
class="form-control text-center mfa-code-mask"
name="code"
maxlength="6"
minlength="6"
aria-required="true"
placeholder="000 000"
autocomplete="off"
style="letter-spacing:5px;font-size:20px;"
aria-label="{{ tr "mfa.template.totp.code" }}">
</div>
<button
class="btn btn-primary btn-block btn-lg"
type="submit"
name="action"
value="verifyTotp"
>
{{ tr "mfa.template.totp.verify" }}
</button>
</form>
{{ else if and (not .totpDisabled) (not .totpPending) (not .totpUnconfigured) }}
<p class="px-3 pt-3 pb-2 mb-0">
<i class="bi bi-check-circle text-success h5 mr-1"></i> {{ tr "mfa.template.totp.confirmed" }}
</p>
{{ end }}
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,52 @@
{{/* setting hideNav=true to root scope and passign it to header template */}}
{{ template "inc_header.html.tpl" set . "hideNav" true }}
<div class="card-body p-0">
{{ if .invalidUser }}
<div class="text-danger font-weight-bold p-3" role="alert">
{{ tr "oauth2-authorize-client.template.errors.invalid-user" "link" links.Profile }}
</div>
{{ end }}
<h4 class="mb-0 p-3 border-bottom">{{ tr "oauth2-authorize-client.template.title" }} "{{ coalesce .client.Name }}"</h1>
<form
action="{{ links.OAuth2AuthorizeClient }}"
method="POST"
class="p-3"
>
{{ .csrfField }}
<p>
{{ tr "oauth2-authorize-client.template.form.greeting-paragraph" }} {{ coalesce .user.Name .user.Handle .user.Email }},
</p>
<p>
<b>{{ .client.Name }}</b> {{ tr "oauth2-authorize-client.template.form.question-for-client" }}
</p>
<p class="text-center">
<button
data-test-id="button-allow"
type="submit"
name="allow"
{{ if .disabled }}disabled{{ end }}
class="btn btn-{{ if .disabled }}secondary{{ else }}primary{{ end }} btn-lg m-2"
style="width:250px;"
>
{{ tr "oauth2-authorize-client.template.form.buttons.allow" }}
</button>
<button
data-test-id="button-deny"
type="submit"
name="deny"
class="btn btn-danger btn-lg m-2"
style="width:250px;"
>
{{ tr "oauth2-authorize-client.template.form.buttons.deny" }}
</button>
</p>
<div class="text-center">
{{ tr "oauth2-authorize-client.template.links.mistake" }}
</div>
</form>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,15 @@
{{ template "inc_header.html.tpl" . }}
<div class="card-body p-0">
<h4 class="mb-0 p-3 border-bottom">{{ tr "password-reset-requested.template.title" }}</h4>
<div
data-test-id="div-reset-instructions"
class="p-3"
role="alert"
>
{{ tr "password-reset-requested.template.instructions" }}
</div>
<div class="text-center my-3">
{{ tr "password-reset-requested.template.links" "signup" links.Signup "login" links.Login }}</a>
</div>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,11 @@
{{ template "inc_header.html.tpl" . }}
<div class="card-body p-0">
<h4 class="mb-0 p-3 border-bottom">{{ tr "pending-email-confirmation.template.title" }}</h4>
<div class="p-3" role="alert">
{{ tr "pending-email-confirmation.template.instructions" }}
</div>
<div class="text-center my-3">
{{ tr "pending-email-confirmation.template.links" "signup" links.Signup "login" links.Login }}</a>
</div>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,146 @@
{{ template "inc_header.html.tpl" set . "activeNav" "profile" }}
<div class="card-body p-0">
<form
method="POST"
action="{{ links.Profile }}"
enctype="multipart/form-data"
class="p-3"
>
{{ .csrfField }}
{{ if .form.error }}
<div
data-test-id="error"
class="text-danger mb-4 font-weight-bold"
role="alert"
>
{{ .form.error }}
</div>
{{ end }}
<div class="mb-3">
<label for="profileFormEmail">{{ tr "profile.template.form.email.label" }}</label>
<input
data-test-id="input-email"
type="email"
class="form-control"
name="email"
id="profileFormEmail"
placeholder="email@domain.ltd"
autocomplete="username"
readonly
value="{{ .form.email }}"
aria-label="{{ tr "profile.template.form.email.label" }}"
>
<div>
{{ if .emailConfirmationRequired }}
<div class="form-text text-danger">
{{ tr "profile.template.form.email.resend-confirmation-link" "link" links.PendingEmailConfirmation }}
</div>
{{ end }}
</div>
</div>
<div class="mb-3">
<label for="profileFormName">{{ tr "profile.template.form.name.label" }}</label>
<input
data-test-id="input-name"
type="text"
class="form-control"
name="name"
id="profileFormName"
placeholder="{{ tr "profile.template.form.name.placeholder" }}"
value="{{ .form.name }}"
autocomplete="name"
aria-label="{{ tr "profile.template.form.name.label" }}"
>
</div>
<div class="mb-3">
<label for="profileFormHandle">{{ tr "profile.template.form.handle.label" }}</label>
<input
data-test-id="input-handle"
type="text"
class="form-control handle-mask"
name="handle"
id="profileFormHandle"
placeholder="{{ tr "profile.template.form.handle.placeholder" }}"
value="{{ .form.handle }}"
autocomplete="handle"
aria-label="{{ tr "profile.template.form.handle.label" }}"
>
</div>
<div class="mb-3">
<label for="profileFormPreferredLanguage">{{ tr "profile.template.form.preferred-language.label" }}</label>
<select
data-test-id="select-language"
class="form-control"
name="preferredLanguage"
id="profileFormPreferredLanguage"
aria-label="{{ tr "profile.template.form.preferred-language.label" }}"
value="{{ .form.preferredLanguage }}"
>
{{ $prefLang := .form.preferredLanguage }}
{{ range .languages }}
<option
value="{{ .Tag }}"
{{ if eq $prefLang .Tag.String }}selected{{ end }}
>
{{ .LocalizedName }} ({{ .Name }})
</option>
{{ end }}
</select>
</div>
{{ if .avatarEnabled }}
<hr/>
<div class="mb-3">
<label for="profileFormHandle">{{ tr "profile.template.form.avatar.label" }}</label>
<div class="d-block">
<img style="height: 4rem; width: 4rem;" class="rounded-circle" src="{{ .form.avatarUrl }}" alt="Profile Photo">
<div class="mt-3">
<label for="avatar" class="p-2 bg-light text-dark rounded" style="cursor: pointer;">
{{ tr "profile.template.form.avatar.upload" }}
</label>
<input id="avatar" name="avatar" value="avatar" type="file" class="sr-only" accept="image/*">
{{ if .isAvatar }}
<button
name="avatar-delete"
value="avatar-delete"
class="ml-2 btn btn-danger"
>
{{ tr "profile.template.form.avatar.delete" }}
</button>
{{ end }}
</div>
</div>
</div>
<div class="form-row mb-3">
<div class="col">
<label for="initialColor">{{ tr "profile.template.form.avatar-initial.color" }}</label>
<input type="color" id="initialColor" class="form-control input-color" value="{{ .form.initialTextColor }}" name="initial-color">
</div>
<div class="col">
<label for="customColor">{{ tr "profile.template.form.avatar-initial.background-color" }}</label>
<input type="color" id="customColor" class="form-control input-color" value="{{ .form.initialBgColor }}" name="initial-bg">
</div>
</div>
{{ end }}
<div>
<button
data-test-id="button-submit"
type="submit"
class="btn btn-primary btn-block btn-lg"
>
{{ tr "profile.template.form.buttons.submit" }}
</button>
</div>
</form>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,49 @@
{{ template "inc_header.html.tpl" . }}
<div class="card-body p-0">
<h4 class="mb-0 p-3 border-bottom">{{ tr "request-password-reset.template.title" }}</h4>
<form
method="POST"
action="{{ links.RequestPasswordReset }}"
class="p-3"
>
{{ .csrfField }}
{{ if .form.error }}
<div
data-test-id="error"
class="text-danger font-weight-bold mb-4"
role="alert"
>
{{ .form.error }}
</div>
{{ end }}
<div class="mb-3">
<label>
{{ tr "request-password-reset.template.form.email.label" }}
</label>
<input
data-test-id="input-email"
type="email"
class="form-control"
name="email"
required
placeholder="{{ tr "request-password-reset.template.form.email.placeholder" }}"
autocomplete="username"
value="{{ if .form }}{{ .form.email }}{{ end }}"
aria-label="{{ tr "request-password-reset.template.form.email.label" }}"
>
</div>
<div class="text-right">
<button
data-test-id="button-password-reset"
class="btn btn-primary btn-block btn-lg"
type="submit"
>
{{ tr "request-password-reset.template.form.buttons.request" }}
</button>
</div>
</form>
<div class="text-center my-3">
{{ tr "request-password-reset.template.links" "signup" links.Signup "login" links.Login }}</a>
</div>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,56 @@
{{ template "inc_header.html.tpl" . }}
<div class="card-body p-0">
<h4 class="mb-0 p-3 border-bottom">{{ tr "reset-password.template.title" }}</h4>
<form
method="POST"
action="{{ links.ResetPassword }}"
class="p-3"
>
{{ .csrfField }}
{{ if .form.error }}
<div class="text-danger font-weight-bold p-3" role="alert">
{{ .form.error }}
</div>
{{ end }}
<div class="mb-3">
<label>
{{ tr "reset-password.template.form.email.label" }}
</label>
<input
data-test-id="input-email"
type="email"
class="form-control"
name="email"
readonly
placeholder="{{ tr "reset-password.template.form.email.placeholder" }}"
value="{{ .user.Email }}"
aria-label="{{ tr "reset-password.template.form.emaillabel" }}"
>
</div>
<div class="mb-3">
<label>
{{ tr "reset-password.template.form.new-password.label" }}
</label>
<input
data-test-id="input-new-password"
type="password"
required
class="form-control"
name="password"
autocomplete="new-password"
placeholder="{{ tr "reset-password.template.form.new-password.placeholder" }}"
aria-label="{{ tr "reset-password.template.form.new-passwordlabel" }}"
>
</div>
<div class="text-right">
<button
data-test-id="button-change-password"
class="btn btn-primary btn-block btn-lg"
type="submit"
>
{{ tr "reset-password.template.form.buttons.change-password" }}
</button>
</div>
</form>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,233 @@
# The following list of templates and scenarios describes
# all possible views (hopefully).
#
# Please read auth/README.adoc for details
error-internal:
Default:
error: |-
Lorem ipsum dolor sit...
login:
All disabled: {}
All enabled:
settings:
LocalEnabled: true
SignupEnabled: true
PasswordResetEnabled: true
ExternalEnabled: true
providers:
- { Label: Google, Handle: google, Icon: google }
- { Label: Facebook, Handle: facebook, Icon: facebook }
- { Label: GitHub, Handle: github, Icon: github }
- { Label: Corteza, Handle: corteza, Icon: shield-lock }
Only external:
settings:
PasswordResetEnabled: true
ExternalEnabled: true
providers:
- { Label: Google, Handle: google, Icon: google }
- { Label: Facebook, Handle: facebook, Icon: facebook }
- { Label: GitHub, Handle: github, Icon: github }
- { Label: Corteza, Handle: corteza, Icon: shield-lock }
Without signup and pass reset:
settings:
LocalEnabled: true
SignupEnabled: false
PasswordResetEnabled: false
With Split credentials check / username:
form:
splitCredentialsCheck: true
settings:
LocalEnabled: true
With errors after submit:
settings:
LocalEnabled: true
form:
email: some.email@example.tld
error: "There was an error..."
security:
Default:
user: { ID: 123, Name: John Doe }
settings:
LocalEnabled: true
MFA enabled:
user: { ID: 123, Name: John Doe }
settings:
LocalEnabled: true
MultiFactor:
TOTP: { Enabled: true }
EmailOTP: { Enabled: true }
MFA enforced by user:
user: { ID: 123, Name: John Doe }
totpEnforced: false
emailOtpEnforced: true
settings:
LocalEnabled: true
MultiFactor:
TOTP: { Enabled: true }
EmailOTP: { Enabled: true }
mfa:
Default: {}
MFA pending:
emailOtpPending: true
totpDisabled: true
TOTP pending:
totpPending: true
With error:
emailOtpPending: true
form:
emailOtpError: "There was an error..."
With alerts (toast):
alerts:
- Text: booya
- Text: Red top border for errors
Type: danger
- Text: Modi sociosqu quibusdam modi delectus ridiculus per potenti sollicitudin dicta porro cumque, gravida odio? Tempora! Molestia
Type: danger
- Text: Primary toast
Type: primary
mfa-totp:
Default:
devQRImage: https://awgsalesservices.com/wp-content/uploads/2019/02/QR-code-example.jpg
TOTP enforced:
enforced: true
devQRImage: https://awgsalesservices.com/wp-content/uploads/2019/02/QR-code-example.jpg
mfa-totp-disable:
Default: {}
With error:
form:
error: "There was an error..."
logout:
Default:
link: '#'
With error:
form:
error: "There was an error..."
signup:
Default: {}
With error:
form:
error: "There was an error..."
reset-password:
Default: {}
request-password-reset:
Default: {}
With error:
form:
error: "There was an error..."
password-reset-requested:
Default: {}
pending-email-confirmation:
Default:
profile:
Default:
user:
ID: 123
Name: John Doe
settings:
ExternalEnabled: true
LocalEnabled: true
With errros:
user:
ID: 123
Name: John Doe
settings:
ExternalEnabled: true
LocalEnabled: true
form:
error: Profile change form submit error
name: foo
email: foo
With unconfirmed email:
user:
ID: 123
Name: John Doe
settings:
ExternalEnabled: true
LocalEnabled: true
form:
error: Profile change form submit error
name: foo
email: foo
emailConfirmationRequired: true
In OAuth2 flow:
user:
ID: 123
Name: John Doe
settings:
ExternalEnabled: true
LocalEnabled: true
client:
Name: foo
emailConfirmationRequired: true
change-password:
Default: {}
With error:
form:
error: "There was an error..."
create-password:
Default: {}
With error:
form:
error: "There was an error..."
authorized-clients:
Empty: {}
Full:
authorizedClients:
- Name: Corteza Web Applications
- Name: Matrix Chat server
- Name: Company's Wordpress
oauth2-authorize-client:
Default:
client:
Name: name of the client
user:
Name: John Doe
Disabled:
invalidUser: Some good reason why this authorization of client is disabled...
disabled: true
client:
Name: name of the client
user:
Name: John Doe
sessions:
Full:
sessions:
- Current: true
ExpiresIn: 3
RemoteAddr: 4.224.2.3
UserAgent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36
- ExpiresIn: 2
RemoteAddr: 4.224.2.3
SameRemoteAddr: true
UserAgent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36
- ExpiresIn: 1
RemoteAddr: 4.224.2.3
SameUserAgent: true
UserAgent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36
- ExpiresIn: 0
UserAgent: Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36

View File

@@ -0,0 +1,122 @@
{{ template "inc_header.html.tpl" set . "activeNav" "security" }}
<div class="card-body p-0">
<form
method="POST"
action="{{ links.Security }}"
class="p-3"
>
{{ if .settings.LocalEnabled }}
<h5>{{ tr "security.template.password.title" }}</h5>
<a
data-test-id="link-change-password"
href="{{ links.ChangePassword }}"
>
{{ tr "security.template.password.change-link" }}
</a>
{{ end }}
<hr />
<div>
{{ .csrfField }}
<h5 class="mb-3">{{ tr "security.template.mfa.title" }}</h5>
{{ if or .settings.MultiFactor.TOTP.Enabled .settings.MultiFactor.EmailOTP.Enabled }}
{{ if .settings.MultiFactor.TOTP.Enabled }}
<div class="mb-4">
<label class="text-primary">{{ tr "security.template.mfa.totp.title" }}</label>
<div class="d-flex align-items-center">
<div>
{{ if .totpEnforced }}
<i class="bi bi-check-circle text-success h5 mr-1"></i>
{{ else }}
<i class="bi bi-exclamation-circle-fill text-danger h5 mr-1"></i>
{{ end }}
{{ if .totpEnforced }}
{{ tr "security.template.mfa.totp.enforced" }}
{{ else }}
{{ tr "security.template.mfa.totp.disabled" }}
{{ end }}
</div>
<div class="ml-auto">
{{ if .totpEnforced }}
{{ if not .settings.MultiFactor.TOTP.Enforced }}
<button
data-test-id="button-disable-totp"
name="action"
value="disableTOTP"
class="btn btn-danger"
>
{{ tr "security.template.mfa.totp.disable" }}
</button>
{{ end }}
{{ else }}
<button
data-test-id="button-configure-totp"
name="action"
value="configureTOTP"
class="btn btn-primary"
>
{{ tr "security.template.mfa.totp.configure" }}
</button>
{{ end }}
</div>
</div>
</div>
{{ end }}
{{ if .settings.MultiFactor.EmailOTP.Enabled }}
<div class="mb-3">
<label class="text-primary">{{ tr "security.template.mfa.email.title" }}</label class="text-primary">
<div class="d-flex align-items-center">
<div>
{{ if .emailOtpEnforced }}
<i class="bi bi-check-circle text-success h5 mr-1"></i>
{{ else }}
<i class="bi bi-exclamation-circle-fill text-danger h5 mr-1"></i>
{{ end }}
{{ if .emailOtpEnforced }}
{{ tr "security.template.mfa.email.enforced" }}
{{ else }}
{{ tr "security.template.mfa.email.disabled" }}
{{ end }}
</div>
<div class="ml-auto">
{{ if .emailOtpEnforced }}
{{ if not .settings.MultiFactor.EmailOTP.Enforced }}
<button
data-test-id="button-disable-email-otp"
name="action"
value="disableEmailOTP"
class="btn btn-danger"
>
{{ tr "security.template.mfa.email.disable" }}
</button>
{{ end }}
{{ else }}
<button
data-test-id="button-enable-email-otp"
name="action"
value="enableEmailOTP"
class="btn btn-primary"
>
{{ tr "security.template.mfa.email.enable" }}
</button>
{{ end }}
</div>
</div>
</div>
{{ end }}
{{ else }}
<div class="mb-1 font-italic" role="alert">
{{ tr "security.template.mfa.all-disabled" }}
</div>
{{ end }}
</div>
</form>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,68 @@
{{ template "inc_header.html.tpl" set . "activeNav" "sessions" }}
<div class="card-body p-0">
<form
method="POST"
action="{{ links.Sessions }}"
>
{{ .csrfField }}
{{- if and .sessions (gt (len .sessions) 1) }}
<div class="mb-0 p-3 border-bottom">
<h4 class="card-title d-inline">{{ tr "sessions.template.title" }}</h4>
<button
data-test-id="button-logout-from-everywhere"
type="submit"
name="delete-all-but-current"
value="true"
class="btn btn-sm btn-danger float-right"
>
{{ tr "sessions.template.delete-all" }}
</button>
</div>
{{ end }}
</form>
<form
method="POST"
action="{{ links.Sessions }}"
class="p-3"
>
{{ .csrfField }}
{{ range .sessions}}
<div class="mb-2 border-bottom">
{{ if .Current }}
<h5>{{ tr "sessions.template.list.current" }}</h5>
{{ end }}
<label class="mb-0 d-block">{{ tr "sessions.template.list.authorized-on" }}</label>
<p class="w-75 d-inline-block">
<time datetime="{{ .CreatedAt }}">{{ .CreatedAt | date "Mon, 02 Jan 2006 15:04 MST" }}</time>
</p>
<label class="mb-0 d-block">{{ tr "sessions.template.list.ip-address" }}</label>
<p class="w-75 d-inline-block">{{ .RemoteAddr }}</p>
{{ if .SameRemoteAddr}}
<span class="badge badge-light float-right">{{ tr "sessions.template.list.same-machine" }}</span>
{{ end }}
<label class="mb-0 d-block">{{ tr "sessions.template.list.expires" }}</label>
<p class="w-75 d-inline-block">
<time datetime="{{ .ExpiresAt }}">{{ .ExpiresAt | date "Mon, 02 Jan 2006 15:04 MST" }}</time>
</p>
{{ if .Expired }}
<span class="badge badge-warning float-right">{{ tr "sessions.template.list.expired" }}</span>
{{ else if eq .ExpiresIn 0 }}
<span class="badge badge-warning float-right">{{ tr "sessions.template.list.today" }}</span>
{{ else if eq .ExpiresIn 1 }}
<span class="badge badge-light float-right">{{ tr "sessions.template.list.tomorrow" }}</span>
{{ else }}
<span class="badge badge-light float-right">{{ tr "sessions.template.list.soon" "days" .ExpiresIn }}</span>
{{ end }}
<label class="mb-0 d-block">{{ tr "sessions.template.list.browser" }}</label>
<p class="small w-75 d-inline-block">
{{ .UserAgent }}
</p>
{{ if .SameUserAgent}}
<span class="badge badge-light float-right">{{ tr "sessions.template.list.same-browser" }}</span>
{{ end }}
</div>
{{ end }}
</form>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,77 @@
{{ template "inc_header.html.tpl" . }}
<div class="card-body p-0">
<h4 class="mb-0 p-3 border-bottom">{{ tr "signup.template.title" }}</h4>
<form
method="POST"
action="{{ links.Signup }}"
class="p-3"
>
{{ .csrfField }}
{{ if .form.error }}
<div
data-test-id="error"
class="text-danger font-weight-bold mb-3"
role="alert"
>
{{ .form.error }}
</div>
{{ end }}
<div class="mb-3">
<label>
{{ tr "signup.template.form.email.label" }}
</label>
<input
data-test-id="input-email"
type="email"
class="form-control"
name="email"
required
placeholder="{{ tr "signup.template.form.email.placeholder" }}"
autocomplete="username"
value="{{ .form.email }}"
aria-label="{{ tr "signup.template.form.email.label" }}">
</div>
<div class="mb-3">
<label>
{{ tr "signup.template.form.password.label" }}
</label>
<input
data-test-id="input-password"
type="password"
class="form-control"
name="password"
required
placeholder="{{ tr "signup.template.form.password.placeholder" }}"
autocomplete="new-password"
aria-label="{{ tr "signup.template.form.password.label" }}">
</div>
<div class="mb-3">
<label>
{{ tr "signup.template.form.name.label" }}
</label>
<input
data-test-id="input-name"
type="text"
class="form-control"
name="name"
placeholder="{{ tr "signup.template.form.name.placeholder" }}"
value="{{ .form.name }}"
autocomplete="name"
aria-label="{{ tr "signup.template.form.name.label" }}">
</div>
<div>
<button
id="submit"
data-test-id="button-submit"
class="btn btn-primary btn-block btn-lg"
type="submit"
>{{ tr "signup.template.form.button.sign-up" }}</button>
</div>
</form>
<div class="text-center mt-2 mb-3">
{{ tr "signup.template.log-in" "link" links.Login }}
</div>
</div>
{{ template "inc_footer.html.tpl" . }}

View File

@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Bootstrap core CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.0/dist/css/bootstrap.min.css" rel="stylesheet">
<script type="application/javascript">
window.addEventListener('load', function() {
document.getElementById('preview').src=location.hash.substring(1)
})
</script>
</head>
<body class="bg-dark text-light">
<div class="container-fluid">
<div class="row m-0 p-0">
<div class="col-3 pl-0">
<sidebar class="vh-100 position-fixed w-25 overflow-auto">
{{ range .templates }}
<div class="pt-2 w-75">
<code>{{ .Template }}.html.tpl</code>
<ul class="nav flex-column">
{{ range .Scenes }}
<li class="nav-item border-top">
<a target="preview"
class="nav-link text-light"
href="/auth/dev/scenarios?template={{ .Template }}&scene={{ .Name }}"
onclick="location.hash=this.href"
>
<div>
{{ .Name }}
</div>
</a>
</li>
{{ end }}
</ul>
</div>
{{ end }}
<sidebar>
</div>
<div class="col-9 m-0 p-0">
<iframe
name="preview"
id="preview"
class="w-100 border-0"
style="height: 100vh;"
src="about:blank"
/>
</div>
</div>
</div>
</body>
</html>

521
server/auth/auth.go Normal file
View File

@@ -0,0 +1,521 @@
package auth
import (
"context"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"embed"
"encoding/json"
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/Masterminds/sprig"
"github.com/cortezaproject/corteza/server/auth/external"
"github.com/cortezaproject/corteza/server/auth/handlers"
"github.com/cortezaproject/corteza/server/auth/oauth2"
"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/actionlog"
"github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/pkg/locale"
"github.com/cortezaproject/corteza/server/pkg/options"
"github.com/cortezaproject/corteza/server/pkg/version"
"github.com/cortezaproject/corteza/server/store"
systemService "github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/go-chi/chi/v5"
oauth2def "github.com/go-oauth2/oauth2/v4"
"go.uber.org/zap"
"golang.org/x/text/language"
)
type (
service struct {
handlers *handlers.AuthHandlers
log *zap.Logger
opt options.AuthOpt
settings *settings.Settings
store store.Storer
}
)
//go:embed assets/public
var PublicAssets embed.FS
// New initializes Auth service that orchestrates session manager, oauth2 manager and http request handlers
func New(ctx context.Context, log *zap.Logger, oa2m oauth2def.Manager, s store.Storer, opt options.AuthOpt, defClient *types.AuthClient) (svc *service, err error) {
var (
tpls templateExecutor
)
log = log.Named("auth")
ctx = actionlog.RequestOriginToContext(ctx, actionlog.RequestOrigin_Auth)
svc = &service{
opt: opt,
log: log,
store: s,
settings: &settings.Settings{ /* all disabled by default. */ },
}
if !opt.LogEnabled {
log = zap.NewNop()
}
sesManager := request.NewSessionManager(s, opt, log)
oauth2Server := oauth2.NewServer(oa2m)
// Called after oauth2 authorization request is validated
// We'll try to get valid user out of the session or redirect user to login page
oauth2Server.SetUserAuthorizationHandler(oauth2.NewUserAuthorizer(
sesManager,
handlers.GetLinks().Login,
handlers.GetLinks().OAuth2AuthorizeClient,
))
oauth2Server.SetClientAuthorizedHandler(func(id string, grant oauth2def.GrantType) (allowed bool, err error) {
// this is a bit silly and a bad design of the oauth2 server lib
// why do we need to keep on load the client??
var (
client *types.AuthClient
)
if client, err = clientLookup(ctx, s, id); err != nil {
return false, fmt.Errorf("could not authorize client: %w", err)
}
// each client only has 1 valid grant type (+ refresh_token)!
if client.ValidGrant != grant.String() && oauth2def.Refreshing != grant {
return false, fmt.Errorf("client does not support %s flow", grant)
}
return true, nil
})
oauth2Server.SetClientScopeHandler(func(tgr *oauth2def.TokenGenerateRequest) (allowed bool, err error) {
// this is a bit silly and a bad design of the oauth2 server lib
// why do we need to keep on load the client??
var (
client *types.AuthClient
)
if client, err = clientLookup(ctx, s, tgr.ClientID); err != nil {
return false, fmt.Errorf("could not authorize client: %w", err)
}
// ensure all requested scopes are allowed on a client
if !auth.CheckScope(client.Scope, strings.Split(tgr.Scope, " ")...) {
return false, fmt.Errorf("client does not allow use of '%s' scope", client.Scope)
}
return true, nil
})
oauth2Server.SetExtensionFieldsHandler(func(ti oauth2def.TokenInfo) (fieldsValue map[string]interface{}) {
fieldsValue = make(map[string]interface{})
handlers.SubSplit(ti, fieldsValue)
fieldsValue["refresh_token_expires_in"] = int(ti.GetRefreshExpiresIn() / time.Second)
if err = userProfile(ctx, s, ti, fieldsValue); err != nil {
log.Error("failed to add profile data", zap.Error(err))
}
return
})
var (
tplLoader templateLoader
tplBase = template.New("").
Funcs(sprig.FuncMap()).
Funcs(template.FuncMap{
"version": func() string { return version.Version },
"buildtime": func() string { return version.BuildTime },
"links": handlers.GetLinks,
// temp, will be replaced
"language": func() string { return language.Tag{}.String() },
"tr": func(key string, pp ...string) string { return key },
})
useEmbedded = len(opt.AssetsPath) == 0
)
if useEmbedded {
tplLoader = EmbeddedTemplates
log.Info("using embedded templates")
} else {
tplLoader = func(t *template.Template) (tpl *template.Template, err error) {
if tpl, err = t.Clone(); err != nil {
return nil, fmt.Errorf("cannot clone templates: %w", err)
} else {
return tpl.ParseGlob(opt.AssetsPath + "/templates/*.tpl")
}
}
log.Info(
"loading templates from filesystem",
zap.String("AUTH_ASSETS_PATH", svc.opt.AssetsPath),
)
}
if !useEmbedded && opt.DevelopmentMode {
log.Info(
"initializing reloadable templates",
zap.Bool("AUTH_DEVELOPMENT_MODE", opt.DevelopmentMode),
)
tpls = NewReloadableTemplates(tplBase, tplLoader)
} else {
log.Info(
"initializing templates without reloading",
zap.Bool("AUTH_DEVELOPMENT_MODE", opt.DevelopmentMode),
)
tpls, err = NewStaticTemplates(tplBase, tplLoader)
if err != nil {
return nil, fmt.Errorf("cannot load templates: %w", err)
}
}
svc.handlers = &handlers.AuthHandlers{
Locale: locale.Global(),
Log: log,
Templates: tpls,
SessionManager: sesManager,
OAuth2: oauth2Server,
AuthService: systemService.DefaultAuth,
CredentialsService: systemService.DefaultCredentials,
UserService: systemService.DefaultUser,
ClientService: &clientService{s},
TokenService: &tokenService{s},
DefaultClient: defClient,
Opt: svc.opt,
Settings: svc.settings,
Attachment: systemService.DefaultAttachment,
}
external.Init(sesManager.Store())
svc.log.Info(
"auth server ready",
zap.String("AUTH_BASE_URL", svc.opt.BaseURL),
zap.String("AUTH_EXTERNAL_REDIRECT_URL", svc.opt.ExternalRedirectURL),
)
return
}
// LoadSamlService takes care of certificate preloading, fetching of the
// IDP metadata once the auth settings are loaded and registers the
// SAML middleware
func (svc *service) LoadSamlService(ctx context.Context, s *settings.Settings) (*saml.SamlSPService, error) {
var (
log = svc.log.Named("saml")
links = handlers.GetLinks()
keyPair tls.Certificate
err error
)
if keyPair, err = tls.X509KeyPair([]byte(s.Saml.Cert), []byte(s.Saml.Key)); err != nil {
return nil, err
}
if keyPair.Leaf, err = x509.ParseCertificate(keyPair.Certificate[0]); err != nil {
return nil, err
}
idpUrl, err := url.Parse(s.Saml.IDP.URL)
if err != nil {
return nil, err
}
// idp metadata needs to be loaded before
// the internal samlsp package
md, err := saml.FetchIDPMetadata(ctx, *idpUrl)
if err != nil {
return nil, err
}
ru, err := url.Parse(svc.opt.BaseURL)
if err != nil {
return nil, err
}
rootURL := &url.URL{
Scheme: ru.Scheme,
User: ru.User,
Host: ru.Host,
}
return saml.NewSamlSPService(log, saml.SamlSPArgs{
Enabled: s.Saml.Enabled,
AcsURL: links.SamlCallback,
MetaURL: links.SamlMetadata,
SloURL: links.SamlLogout,
IdpURL: *idpUrl,
Host: *rootURL,
Certificate: keyPair.Leaf,
PrivateKey: keyPair.PrivateKey.(*rsa.PrivateKey),
IdpMeta: md,
SignRequests: s.Saml.SignRequests,
SignatureMethod: s.Saml.SignMethod,
Binding: s.Saml.Binding,
IdentityPayload: saml.IdpIdentityPayload{
Name: s.Saml.IDP.IdentName,
Handle: s.Saml.IDP.IdentHandle,
Identifier: s.Saml.IDP.IdentIdentifier,
},
})
}
func (svc *service) UpdateSettings(s *settings.Settings) {
if svc.settings.LocalEnabled != s.LocalEnabled {
svc.log.Debug("setting changed", zap.Bool("localEnabled", s.LocalEnabled))
}
if svc.settings.SignupEnabled != s.SignupEnabled {
svc.log.Debug("setting changed", zap.Bool("signupEnabled", s.SignupEnabled))
}
if svc.settings.EmailConfirmationRequired != s.EmailConfirmationRequired {
svc.log.Debug("setting changed", zap.Bool("emailConfirmationRequired", s.EmailConfirmationRequired))
}
if svc.settings.PasswordResetEnabled != s.PasswordResetEnabled {
svc.log.Debug("setting changed", zap.Bool("passwordResetEnabled", s.PasswordResetEnabled))
}
if svc.settings.SplitCredentialsCheck != s.SplitCredentialsCheck {
svc.log.Debug("setting changed", zap.Bool("splitCredentialsCheck", s.SplitCredentialsCheck))
}
if svc.settings.ExternalEnabled != s.ExternalEnabled {
svc.log.Debug("setting changed", zap.Bool("externalEnabled", s.ExternalEnabled))
}
if svc.settings.MultiFactor != s.MultiFactor {
svc.log.Debug("setting changed", zap.Any("mfa", s.MultiFactor))
}
if svc.settings.Saml != s.Saml {
var (
log = svc.log.Named("saml")
ss *saml.SamlSPService
err error
)
log.Debug("setting updated")
switch true {
case s.Saml.Cert == "", s.Saml.Key == "":
log.Warn("certificate private/public keys empty (see 'auth.external.saml' settings)")
break
case s.Saml.IDP.URL == "":
log.Warn("could not get IDP url (see 'auth.external.saml.idp')")
break
default:
ss, err = svc.LoadSamlService(context.Background(), s)
if err != nil {
log.Warn("could not reload service", zap.Error(err))
}
}
if ss != nil {
log.Info("reloading service")
svc.handlers.SamlSPService = ss
}
}
// Always reload external providers.
// This could be optionally skipped by strictly comparing if they actually changed.
// OIDC complicates it a bit wit all the mix and matching.
// Should be ok for now.
// @todo see if we can conditionally skip this
svc.log.Debug("setting changed", zap.Int("providers", len(s.Providers)))
external.SetupGothProviders(svc.log, svc.opt.ExternalRedirectURL, s.Providers...)
svc.settings = s
svc.handlers.Settings = s
}
func (svc *service) Watch(ctx context.Context) {
go svc.gc(ctx)
}
func (svc service) gc(ctx context.Context) {
svc.log.Info("running startup garbage collection")
go svc.gcSessions(ctx)
go svc.gcOAuth2Tokens(ctx)
i := svc.opt.GarbageCollectorInterval
if i < time.Minute {
svc.log.Warn("garbage collection interval less than 1 minute, disabling")
} else {
svc.log.Info("starting garbage collecting process", zap.Duration("interval", i))
}
tck := time.NewTicker(i)
for {
select {
case <-ctx.Done():
svc.log.Info("stopping gc", zap.Error(ctx.Err()))
return
case <-tck.C:
svc.log.Info("garbage collector")
go svc.gcSessions(ctx)
go svc.gcOAuth2Tokens(ctx)
return
}
}
}
func (svc service) gcSessions(ctx context.Context) {
err := store.DeleteExpiredAuthSessions(ctx, svc.store)
if err != nil {
svc.log.Error("failed to collect session garbage", zap.Error(err))
}
}
func (svc service) gcOAuth2Tokens(ctx context.Context) {
err := store.DeleteExpiredAuthOA2Tokens(ctx, svc.store)
if err != nil {
svc.log.Error("failed to collect oauth2 token garbage", zap.Error(err))
}
}
func (svc service) MountHttpRoutes(basePath string, r chi.Router) {
basePath = strings.TrimRight(basePath, "/")
svc.handlers.MountHttpRoutes(r)
const uriRoot = "/auth/assets/public"
var (
assetHandler http.Handler
useEmbedded = len(svc.opt.AssetsPath) == 0
)
if useEmbedded {
assetHandler = http.StripPrefix(basePath+"/auth/", http.FileServer(http.FS(PublicAssets)))
} else {
var root = strings.TrimRight(svc.opt.AssetsPath, "/") + "/public"
if err := dirCheck(root); err != nil {
svc.log.Error(
"failed to configure auth assets handler",
zap.Error(err),
zap.String("AUTH_ASSETS_PATH", svc.opt.AssetsPath),
)
} else {
assetHandler = http.StripPrefix(basePath+uriRoot, http.FileServer(http.Dir(root)))
}
}
// fallback to embedded assets
r.Handle(uriRoot+"/*", assetHandler)
}
// checks if directory exists & is readable
func dirCheck(path string) (err error) {
_, err = os.Stat(path)
if !os.IsNotExist(err) {
return
}
return
}
// WellKnownOpenIDConfiguration returns the OIDC discovery document
//
// Issuer must match the "iss" claim we put on the issued ID tokens (AUTH_BASE_URL)
func (svc service) WellKnownOpenIDConfiguration() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"issuer": svc.opt.BaseURL,
"authorization_endpoint": svc.opt.BaseURL + "/oauth2/authorize",
"token_endpoint": svc.opt.BaseURL + "/oauth2/token",
"jwks_uri": svc.opt.BaseURL + "/oauth2/public-keys",
"userinfo_endpoint": svc.opt.BaseURL + "/oauth2/userinfo",
// ID tokens are signed with the client's secret
"id_token_signing_alg_values_supported": []string{"HS512"},
// see auth/oauth2/oauth2.go for the values the server actually allows
"response_types_supported": []string{"code"},
"grant_types_supported": []string{
"authorization_code",
"refresh_token",
"client_credentials",
},
"code_challenge_methods_supported": []string{"plain", "S256"},
"subject_types_supported": []string{"public"},
"token_endpoint_auth_methods_supported": []string{
"client_secret_basic",
"client_secret_post",
},
"scopes_supported": []string{"openid", "profile", "email", "api"},
"claims_supported": []string{
"sub",
"iss",
"aud",
"exp",
"iat",
"name",
"preferred_username",
"email",
"email_verified",
"locale",
"updated_at",
},
// kept for backward compatibility, correct field name is scopes_supported
"scope_supported": []string{"profile", "api"},
})
}
}
// Profile fills map with user's data
//
// If scope supports it (contains "profile") user is loaded and
// map is filled with username (handle), email and name
func userProfile(ctx context.Context, s store.Users, ti oauth2def.TokenInfo, data map[string]interface{}) error {
if !auth.CheckScope(ti.GetScope(), "profile") {
return nil
}
userID, _ := auth.ExtractFromSubClaim(ti.GetUserID())
if userID == 0 {
return fmt.Errorf("invalid user ID in 'sub' claim")
}
user, err := store.LookupUserByID(ctx, s, userID)
if err != nil {
return err
}
data["handle"] = user.Handle
data["name"] = user.Name
data["email"] = user.Email
if user.Meta != nil && user.Meta.PreferredLanguage != "" {
data["preferred_language"] = user.Meta.PreferredLanguage
}
return nil
}

31
server/auth/auth_test.go Normal file
View File

@@ -0,0 +1,31 @@
package auth
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/cortezaproject/corteza/server/pkg/options"
"github.com/stretchr/testify/require"
)
func Test_WellKnownOpenIDConfiguration(t *testing.T) {
var (
req = require.New(t)
svc = service{opt: options.AuthOpt{BaseURL: "https://example.tld/auth"}}
rec = httptest.NewRecorder()
)
svc.WellKnownOpenIDConfiguration()(rec, httptest.NewRequest(http.MethodGet, "/.well-known/openid-configuration", nil))
var doc map[string]interface{}
req.NoError(json.Unmarshal(rec.Body.Bytes(), &doc))
// issuer must match the "iss" claim of the issued ID tokens
req.Equal("https://example.tld/auth", doc["issuer"])
req.Equal("https://example.tld/auth/oauth2/authorize", doc["authorization_endpoint"])
req.Equal("https://example.tld/auth/oauth2/token", doc["token_endpoint"])
req.Equal("https://example.tld/auth/oauth2/userinfo", doc["userinfo_endpoint"])
}

47
server/auth/client.go Normal file
View File

@@ -0,0 +1,47 @@
package auth
import (
"context"
"github.com/cortezaproject/corteza/server/pkg/handle"
"github.com/cortezaproject/corteza/server/store"
systemService "github.com/cortezaproject/corteza/server/system/service"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/spf13/cast"
)
type (
clientService struct {
store interface {
store.AuthClients
store.AuthConfirmedClients
}
}
)
func (svc clientService) Lookup(ctx context.Context, identifier interface{}) (*types.AuthClient, error) {
return clientLookup(ctx, svc.store, identifier)
}
func (svc clientService) LookupByHandle(ctx context.Context, handle string) (*types.AuthClient, error) {
return store.LookupAuthClientByHandle(ctx, svc.store, handle)
}
func (svc clientService) Confirmed(ctx context.Context, userID uint64) (types.AuthConfirmedClientSet, error) {
set, _, err := store.SearchAuthConfirmedClients(ctx, svc.store, types.AuthConfirmedClientFilter{UserID: userID})
return set, err
}
func (svc clientService) Revoke(ctx context.Context, userID, clientID uint64) error {
return store.DeleteAuthConfirmedClientByUserIDClientID(ctx, svc.store, userID, clientID)
}
func clientLookup(ctx context.Context, s store.AuthClients, identifier interface{}) (*types.AuthClient, error) {
if id := cast.ToUint64(identifier); id > 0 {
return store.LookupAuthClientByID(ctx, s, id)
} else if h := cast.ToString(identifier); handle.IsValid(h) {
return store.LookupAuthClientByHandle(ctx, s, h)
} else {
return nil, systemService.AuthClientErrInvalidID()
}
}

View File

@@ -0,0 +1,109 @@
package commands
import (
"embed"
"os"
"path"
"github.com/cortezaproject/corteza/server/auth"
"github.com/cortezaproject/corteza/server/pkg/cli"
"github.com/spf13/cobra"
)
func assets(app serviceInitializer) *cobra.Command {
cmd := &cobra.Command{
Use: "assets",
Short: "Authentication flow assets (styling, images) and templates",
}
exportCmd := &cobra.Command{
Use: "export",
Short: "Exports embedded assets into provided path (must exists)",
Args: cobra.MaximumNArgs(1),
Run: func(cmd *cobra.Command, args []string) {
assetsRoot := app.Options().Auth.AssetsPath
if len(args) > 0 {
assetsRoot = args[0]
}
if len(assetsRoot) == 0 {
cmd.Println("can not export, no path provided and AUTH_ASSETS_PATH is empty")
return
}
if _, err := os.Stat(assetsRoot); err != nil {
cli.HandleError(err)
}
emb := map[string]embed.FS{
"public": auth.PublicAssets,
"templates": auth.Templates,
}
var (
fh *os.File
buf []byte
dst string
src string
assetsSub string
)
for dir, efs := range emb {
assetsSub = path.Join(assetsRoot, dir)
cmd.Printf("exporting auth assets to %s\n", dir)
if _, err := os.Stat(assetsSub); os.IsNotExist(err) {
cli.HandleError(os.Mkdir(assetsSub, 0755))
cmd.Println("directory created")
} else if err != nil {
cli.HandleError(err)
}
cc, err := efs.ReadDir(path.Join("assets", dir))
if err != nil {
cli.HandleError(err)
}
for _, c := range cc {
src = path.Join("assets", dir, c.Name())
dst = path.Join(assetsSub, c.Name())
if c.IsDir() {
cmd.Println("skipping directory:", assetsRoot)
continue
}
cmd.Print("exporting asset ", dst, ": ")
buf, err = efs.ReadFile(src)
if err != nil {
cmd.Println("\n => error:", err.Error())
continue
}
fh, err = os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0666)
if os.IsExist(err) {
cmd.Println("exists")
continue
}
if err != nil {
cmd.Println("\n => error:", err.Error())
continue
}
_, err = fh.Write(buf)
if err != nil {
cmd.Println("\n => error:", err.Error())
continue
}
cmd.Println("ok")
}
}
},
}
cmd.AddCommand(exportCmd)
return cmd
}

View File

@@ -0,0 +1,184 @@
package commands
import (
"context"
"time"
"github.com/cortezaproject/corteza/server/auth/external"
"github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/pkg/cli"
"github.com/cortezaproject/corteza/server/pkg/logger"
"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/spf13/cobra"
)
type (
serviceInitializer interface {
InitServices(ctx context.Context) error
Activate(ctx context.Context) error
Options() *options.Options
}
)
func commandPreRunInitService(app serviceInitializer) func(*cobra.Command, []string) error {
return func(_ *cobra.Command, _ []string) error {
return app.InitServices(cli.Context())
}
}
func commandPreRunInitActivate(app serviceInitializer) func(*cobra.Command, []string) error {
return func(_ *cobra.Command, _ []string) error {
return app.Activate(cli.Context())
}
}
func Command(ctx context.Context, app serviceInitializer, storeInit func(ctx context.Context) (store.Storer, error)) *cobra.Command {
var (
enableDiscoveredProvider bool
skipValidationOnAutoDiscoveredProvider bool
clientID uint64
scope []string
tokenDuration time.Duration
)
cmd := &cobra.Command{
Use: "auth",
Short: "Authentication management",
}
autoDiscoverCmd := &cobra.Command{
Use: "auto-discovery [name] [url]",
Short: "Auto discovers new OIDC client",
Args: cobra.ExactArgs(2),
PreRunE: commandPreRunInitService(app),
Run: func(cmd *cobra.Command, args []string) {
ctx := cli.Context()
s, err := storeInit(ctx)
cli.HandleError(err)
_, err = external.RegisterOidcProvider(
ctx,
logger.Default(),
s,
app.Options().Auth,
args[0],
args[1],
true,
!skipValidationOnAutoDiscoveredProvider,
enableDiscoveredProvider,
)
cli.HandleError(err)
if enableDiscoveredProvider {
cmd.Println("OIDC provider successfully added and enabled.")
} else {
cmd.Println("OIDC provider successfully added (still disabled).")
}
},
}
autoDiscoverCmd.Flags().BoolVar(
&enableDiscoveredProvider,
"enable",
false,
"Enable this provider and external auth")
autoDiscoverCmd.Flags().BoolVar(
&skipValidationOnAutoDiscoveredProvider,
"skip-validation",
false,
"Skip validation")
jwtCmd := &cobra.Command{
Use: "jwt [email-or-id]",
Short: "Generates new JWT for a user",
Args: cobra.MinimumNArgs(1),
PreRunE: commandPreRunInitService(app),
Run: func(cmd *cobra.Command, args []string) {
ctx = auth.SetIdentityToContext(ctx, auth.ServiceUser())
var (
signedToken []byte
user *types.User
err error
userStr = args[0]
)
user, err = service.DefaultUser.FindByAny(ctx, userStr)
cli.HandleError(err)
err = service.DefaultAuth.LoadRoleMemberships(ctx, user)
cli.HandleError(err)
opts := []auth.IssueOptFn{
auth.WithIdentity(user),
auth.WithScope(scope...),
}
if clientID > 0 {
opts = append(opts, auth.WithClientID(clientID))
}
if tokenDuration > 0 {
opts = append(opts, auth.WithExpiration(tokenDuration))
}
signedToken, err = auth.TokenIssuer.Issue(ctx, opts...)
cli.HandleError(err)
cmd.Println(string(signedToken))
},
}
jwtCmd.Flags().Uint64Var(
&clientID,
"auth-client",
0,
"ID if the auth client")
jwtCmd.Flags().StringArrayVar(
&scope,
"scope",
[]string{"profile", "api"},
"Scope")
jwtCmd.Flags().DurationVar(
&tokenDuration,
"duration",
app.Options().Auth.AccessTokenLifetime,
"Token expiry duration")
testEmails := &cobra.Command{
Use: "test-notifications [recipient]",
Short: "Sends samples of all authentication notification to recipient",
Args: cobra.ExactArgs(1),
PreRunE: commandPreRunInitActivate(app),
Run: func(cmd *cobra.Command, args []string) {
var (
err error
ntf = service.DefaultAuthNotification
)
// Update current settings to be sure that we do not have outdated values
cli.HandleError(service.DefaultSettings.UpdateCurrent(ctx))
err = ntf.PasswordReset(ctx, args[0], "notification-testing-token")
cli.HandleError(err)
},
}
cmd.AddCommand(
autoDiscoverCmd,
testEmails,
jwtCmd,
assets(app),
)
return cmd
}

90
server/auth/external/auth_handler.go vendored Normal file
View File

@@ -0,0 +1,90 @@
package external
import (
"net/http"
"github.com/cortezaproject/corteza/server/auth/saml"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/crewjam/saml/samlsp"
"github.com/markbates/goth/gothic"
)
type (
ExternalAuthHandler interface {
BeginUserAuth(http.ResponseWriter, *http.Request)
CompleteUserAuth(http.ResponseWriter, *http.Request) (u *types.ExternalAuthUser, err error)
}
externalSamlAuthHandler struct {
service *saml.SamlSPService
}
externalDefaultAuthHandler struct{}
)
func (eh *externalDefaultAuthHandler) BeginUserAuth(w http.ResponseWriter, r *http.Request) {
gothic.BeginAuthHandler(w, r)
}
func (eh *externalDefaultAuthHandler) CompleteUserAuth(w http.ResponseWriter, r *http.Request) (u *types.ExternalAuthUser, err error) {
gu, err := gothic.CompleteUserAuth(w, r)
u = &types.ExternalAuthUser{gu}
return
}
func (eh *externalSamlAuthHandler) BeginUserAuth(w http.ResponseWriter, r *http.Request) {
_, err := eh.service.Handler().Session.GetSession(r)
if err == samlsp.ErrNoSession {
eh.service.Handler().HandleStartAuthFlow(w, r)
}
}
func (eh *externalSamlAuthHandler) CompleteUserAuth(w http.ResponseWriter, r *http.Request) (u *types.ExternalAuthUser, err error) {
var (
session samlsp.Session
)
if session, err = eh.service.Handler().Session.GetSession(r); err != nil {
return
}
if session != nil {
sess := session.(samlsp.JWTSessionClaims)
if sess.StandardClaims.Valid() != nil {
return nil, sess.StandardClaims.Valid()
}
u = &types.ExternalAuthUser{}
u.Provider = "saml"
// get identifier for use with Corteza (email)
u.Email = eh.service.GuessIdentifier(sess.Attributes)
// try to get email from jwt claims
if u.Email == "" {
u.Email = sess.StandardClaims.Subject
u.Name = sess.StandardClaims.Subject
u.NickName = sess.StandardClaims.Subject
} else {
u.Name = sess.Attributes.Get(eh.service.IDPUserMeta.Name)
u.NickName = sess.Attributes.Get(eh.service.IDPUserMeta.Handle)
}
u.UserID = sess.Attributes.Get("SessionIndex")
}
return
}
func NewSamlExternalHandler(s *saml.SamlSPService) *externalSamlAuthHandler {
return &externalSamlAuthHandler{
service: s,
}
}
func NewDefaultExternalHandler() *externalDefaultAuthHandler {
return &externalDefaultAuthHandler{}
}

15
server/auth/external/external.go vendored Normal file
View File

@@ -0,0 +1,15 @@
package external
import (
"github.com/gorilla/sessions"
"github.com/markbates/goth/gothic"
)
const (
OIDC_PROVIDER_PREFIX = "openid-connect." // must match const in "github.com/cortezaproject/corteza/server/system/types" app_settings.go
)
func Init(store sessions.Store) {
gothic.Store = store
}

66
server/auth/external/external_test.go vendored Normal file
View File

@@ -0,0 +1,66 @@
package external
import (
"net/url"
"reflect"
"testing"
)
func Test_parseExternalProviderUrl(t *testing.T) {
mustParseURL := func(in string) *url.URL {
r, err := url.Parse(in)
if err != nil {
panic(err)
}
return r
}
type args struct {
in string
}
tests := []struct {
name string
args args
wantP *url.URL
wantErr bool
}{
{
"happy-path",
args{"https://foo.bar"},
mustParseURL("https://foo.bar"),
false,
},
{
"bad input",
args{":\\"},
nil,
true,
},
{
"add schema",
args{"cortezaproject.org"},
mustParseURL("https://cortezaproject.org"),
false,
},
{
"add schema and remove well-known",
args{"cortezaproject.org/some-subdir/" + WellKnown},
mustParseURL("https://cortezaproject.org/some-subdir/"),
false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotP, err := parseExternalProviderUrl(tt.args.in)
if (err != nil) != tt.wantErr {
t.Errorf("parseExternalProviderUrl() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(gotP, tt.wantP) {
t.Errorf("parseExternalProviderUrl() gotP = %v, want %v", gotP, tt.wantP)
}
})
}
}

89
server/auth/external/goth.go vendored Normal file
View File

@@ -0,0 +1,89 @@
package external
import (
"strings"
"github.com/cortezaproject/corteza/server/auth/external/nylas"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/markbates/goth"
"github.com/markbates/goth/providers/facebook"
"github.com/markbates/goth/providers/github"
"github.com/markbates/goth/providers/google"
"github.com/markbates/goth/providers/linkedin"
"github.com/markbates/goth/providers/openidConnect"
"go.uber.org/zap"
)
// We're expecting that our users will be able to complete
// external auth loop in 15 minutes.
const (
WellKnown = "/.well-known/openid-configuration"
)
func SetupGothProviders(log *zap.Logger, redirectUrl string, ep ...settings.Provider) {
var (
err error
)
// Purge all previously configured providers
if l := len(goth.GetProviders()); l > 0 {
log.Debug("removing existing providers", zap.Int("count", l))
goth.ClearProviders()
}
log.Debug("initializing enabled external authentication providers", zap.Int("count", len(ep)))
for _, pc := range ep {
var provider goth.Provider
log := log.With(zap.String("provider", pc.Handle))
redirect := pc.RedirectUrl
if redirect == "" {
// If redirect URL is not explicitly set for this provider,
// generate one from template string
redirect = strings.Replace(redirectUrl, "{provider}", pc.Handle, 1)
}
if strings.HasPrefix(pc.Handle, OIDC_PROVIDER_PREFIX) {
if pc.IssuerUrl == "" {
log.Error("failed to discover OIDC provider, URL empty")
continue
}
wellKnown := strings.TrimSuffix(pc.IssuerUrl, "/") + WellKnown
var scope []string
if len(pc.Scope) > 0 {
scope = strings.Split(pc.Scope, " ")
} else {
scope = append(scope, "email")
}
if provider, err = openidConnect.New(pc.Key, pc.Secret, redirect, wellKnown, scope...); err != nil {
log.Error("failed to discover OIDC provider", zap.Error(err), zap.String("well-known", wellKnown))
continue
} else {
provider.SetName(pc.Handle)
}
} else {
switch pc.Handle {
case "github":
provider = github.New(pc.Key, pc.Secret, redirect, "user:email")
case "facebook":
provider = facebook.New(pc.Key, pc.Secret, redirect, "email")
case "google":
provider = google.New(pc.Key, pc.Secret, redirect, "email")
case "linkedin":
provider = linkedin.New(pc.Key, pc.Secret, redirect, "email")
case "nylas":
provider = nylas.New(pc.Key, pc.Secret, redirect, "email")
}
}
if provider != nil {
log.Info("external authentication provider added", zap.String("key", pc.Key))
goth.UseProviders(provider)
}
}
}

13
server/auth/external/main_test.go vendored Normal file
View File

@@ -0,0 +1,13 @@
package external
import (
"os"
"testing"
"github.com/cortezaproject/corteza/server/pkg/logger"
)
func TestMain(m *testing.M) {
logger.SetDefault(logger.MakeDebugLogger())
os.Exit(m.Run())
}

172
server/auth/external/nylas/nylas.go vendored Normal file
View File

@@ -0,0 +1,172 @@
package nylas
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"net/http"
"github.com/markbates/goth"
"golang.org/x/oauth2"
)
// These vars define the Authentication, Token, and API URLS for Nylas.
var (
AuthURL = "https://api.nylas.com/oauth/authorize"
TokenURL = "https://api.nylas.com/oauth/token"
ProfileURL = "https://api.Nylas.com/account"
)
// New creates a new Nylas provider, and sets up important connection details.
// You should always call `Nylas.New` to get a new Provider. Never try to create
// one manually.
func New(clientKey, secret, callbackURL string, scopes ...string) *Provider {
return NewCustomisedURL(clientKey, secret, callbackURL, AuthURL, TokenURL, ProfileURL, scopes...)
}
// NewCustomisedURL is similar to New(...) but can be used to set custom URLs to connect to
func NewCustomisedURL(clientKey, secret, callbackURL, authURL, tokenURL, profileURL string, scopes ...string) *Provider {
p := &Provider{
ClientKey: clientKey,
Secret: secret,
CallbackURL: callbackURL,
providerName: "nylas",
profileURL: profileURL,
}
p.config = newConfig(p, authURL, tokenURL, scopes)
return p
}
// Provider is the implementation of `goth.Provider` for accessing Nylas.
type Provider struct {
ClientKey string
Secret string
CallbackURL string
HTTPClient *http.Client
config *oauth2.Config
providerName string
profileURL string
}
// Name is the name used to retrieve this provider later.
func (p *Provider) Name() string {
return p.providerName
}
// SetName is to update the name of the provider (needed in case of multiple providers of 1 type)
func (p *Provider) SetName(name string) {
p.providerName = name
}
func (p *Provider) Client() *http.Client {
return goth.HTTPClientWithFallBack(p.HTTPClient)
}
// Debug is a no-op for the Nylas package.
func (p *Provider) Debug(debug bool) {}
// BeginAuth asks Nylas for an authentication end-point.
func (p *Provider) BeginAuth(state string) (goth.Session, error) {
url := p.config.AuthCodeURL(state)
session := &Session{
AuthURL: url,
}
return session, nil
}
// FetchUser will go to Nylas and access basic information about the user.
func (p *Provider) FetchUser(session goth.Session) (goth.User, error) {
sess := session.(*Session)
user := goth.User{
AccessToken: sess.AccessToken,
Provider: p.Name(),
}
if user.AccessToken == "" {
// data is not yet retrieved since accessToken is still empty
return user, fmt.Errorf("%s cannot get user information without accessToken", p.providerName)
}
req, err := http.NewRequest("GET", p.profileURL, nil)
if err != nil {
return user, err
}
req.Header.Add("Authorization", "Bearer "+sess.AccessToken)
response, err := p.Client().Do(req)
if err != nil {
return user, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return user, fmt.Errorf("Nylas API responded with a %d trying to fetch user information", response.StatusCode)
}
bits, err := ioutil.ReadAll(response.Body)
if err != nil {
return user, err
}
err = json.NewDecoder(bytes.NewReader(bits)).Decode(&user.RawData)
if err != nil {
return user, err
}
err = userFromReader(bytes.NewReader(bits), &user)
if err != nil {
return user, err
}
return user, err
}
func userFromReader(reader io.Reader, user *goth.User) error {
u := struct {
ID string `json:"id"`
Email string `json:"email_address"`
Name string `json:"name"`
}{}
err := json.NewDecoder(reader).Decode(&u)
if err != nil {
return err
}
user.Name = u.Name
user.NickName = u.Name
user.Email = u.Email
user.UserID = u.ID
return err
}
func newConfig(provider *Provider, authURL, tokenURL string, scopes []string) *oauth2.Config {
c := &oauth2.Config{
ClientID: provider.ClientKey,
ClientSecret: provider.Secret,
RedirectURL: provider.CallbackURL,
Endpoint: oauth2.Endpoint{
AuthURL: authURL,
TokenURL: tokenURL,
},
Scopes: []string{},
}
c.Scopes = append(c.Scopes, scopes...)
return c
}
//RefreshToken refresh token is not provided by Nylas
func (p *Provider) RefreshToken(refreshToken string) (*oauth2.Token, error) {
return nil, errors.New("Refresh token is not provided by Nylas")
}
//RefreshTokenAvailable refresh token is not provided by Nylas
func (p *Provider) RefreshTokenAvailable() bool {
return false
}

56
server/auth/external/nylas/session.go vendored Normal file
View File

@@ -0,0 +1,56 @@
package nylas
import (
"encoding/json"
"errors"
"strings"
"github.com/markbates/goth"
)
// Session stores data during the auth process with Nylas.
type Session struct {
AuthURL string
AccessToken string
}
// GetAuthURL will return the URL set by calling the `BeginAuth` function on the Nylas provider.
func (s Session) GetAuthURL() (string, error) {
if s.AuthURL == "" {
return "", errors.New(goth.NoAuthUrlErrorMessage)
}
return s.AuthURL, nil
}
// Authorize the session with Nylas and return the access token to be stored for future use.
func (s *Session) Authorize(provider goth.Provider, params goth.Params) (string, error) {
p := provider.(*Provider)
token, err := p.config.Exchange(goth.ContextForClient(p.Client()), params.Get("code"))
if err != nil {
return "", err
}
if !token.Valid() {
return "", errors.New("Invalid token received from provider")
}
s.AccessToken = token.AccessToken
return token.AccessToken, err
}
// Marshal the session into a string
func (s Session) Marshal() string {
b, _ := json.Marshal(s)
return string(b)
}
func (s Session) String() string {
return s.Marshal()
}
// UnmarshalSession will unmarshal a JSON string into a session.
func (p *Provider) UnmarshalSession(data string) (goth.Session, error) {
sess := &Session{}
err := json.NewDecoder(strings.NewReader(data)).Decode(sess)
return sess, err
}

253
server/auth/external/register.go vendored Normal file
View File

@@ -0,0 +1,253 @@
package external
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"net/url"
"strings"
"github.com/cortezaproject/corteza/server/pkg/options"
"github.com/cortezaproject/corteza/server/store"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/crusttech/go-oidc"
"go.uber.org/zap"
)
func AddProvider(ctx context.Context, log *zap.Logger, s store.SettingValues, eap *types.ExternalAuthProvider, force bool) error {
var (
prefix = "auth.external.providers." + eap.Key + "."
)
log = log.With(
zap.Bool("force", force),
zap.String("handle", eap.Handle),
zap.String("key", eap.Key),
)
if eap.IssuerUrl != "" {
log = log.With(zap.String("issuer-url", eap.IssuerUrl))
}
log.Info("adding external authentication provider")
ss, _, err := store.SearchSettingValues(ctx, s, types.SettingsFilter{
Prefix: prefix,
})
if err != nil {
return err
}
ex := ss.KV().CutPrefix(prefix)
if !force {
// check if exists before storing it
if len(ex) > 0 && ex.String("key") == eap.Key && ex.String("secret") == eap.Secret {
return nil
}
}
if vv, err := eap.EncodeKV(); err != nil {
return fmt.Errorf("could not encode auth provider values: %w", err)
} else if err = store.UpsertSettingValue(ctx, s, vv...); err != nil {
return fmt.Errorf("could not store auth provider values: %w", err)
}
log.Info("external authentication provider added")
return nil
}
// @todo remove dependency on github.com/crusttech/go-oidc (and github.com/coreos/go-oidc)
//
// and move client registration to corteza codebase
func DiscoverOidcProvider(ctx context.Context, log *zap.Logger, opt options.AuthOpt, name, url string) (eap *types.ExternalAuthProvider, err error) {
var (
provider *oidc.Provider
client *oidc.Client
redirectUrl = strings.Replace(opt.ExternalRedirectURL, "{provider}", OIDC_PROVIDER_PREFIX+name, 1)
)
log = log.With(
zap.String("name", name),
zap.String("url", url),
)
if provider, err = oidc.NewProvider(ctx, url); err != nil {
return
}
client, err = provider.RegisterClient(ctx, &oidc.ClientRegistration{
Name: "Corteza",
RedirectURIs: []string{redirectUrl},
ResponseTypes: []string{"token id_token", "code"},
})
if err != nil {
log.Error("could not register oidc provider", zap.Error(err))
return
}
eap = &types.ExternalAuthProvider{
Handle: OIDC_PROVIDER_PREFIX + name,
RedirectUrl: redirectUrl,
Key: client.ID,
Secret: client.Secret,
IssuerUrl: url,
}
log.Info("oidc provider registered", zap.String("key", client.ID))
return
}
func RegisterOidcProvider(ctx context.Context, log *zap.Logger, s store.SettingValues, opt options.AuthOpt, name, providerUrl string, force, validate, enable bool) (eap *types.ExternalAuthProvider, err error) {
if !force {
prefix := "auth.external.providers." + eap.Key + "."
vv, _, err := store.SearchSettingValues(ctx, s, types.SettingsFilter{
Prefix: prefix,
})
if err != nil || len(vv) > 0 {
return nil, err
}
}
if validate {
// Do basic validation of external auth settings
// will fail if secret or url are not set
if err = staticValidateExternal(opt); err != nil {
return
}
// Do full rediredct-URL check
if err = validateExternalRedirectURL(opt); err != nil {
return
}
}
if opt.ExternalRedirectURL == "" {
return nil, fmt.Errorf("refusing to register OIDC provider without redirect url")
}
p, err := parseExternalProviderUrl(providerUrl)
if err != nil {
return
}
eap, err = DiscoverOidcProvider(ctx, log, opt, name, p.String())
if err != nil {
return
}
vv, err := eap.EncodeKV()
if err != nil {
return
}
if enable {
err = vv.Walk(func(value *types.SettingValue) error {
if strings.HasSuffix(value.Name, ".enabled") {
return value.SetSetting(true)
}
return nil
})
if err != nil {
return
}
v := &types.SettingValue{Name: "auth.external.enabled"}
err = v.SetSetting(true)
if err != nil {
return
}
vv = append(vv, v)
}
err = store.UpsertSettingValue(ctx, s, vv...)
if err != nil {
return
}
return
}
func parseExternalProviderUrl(in string) (p *url.URL, err error) {
if i := strings.Index(in, "://"); i == -1 {
// Add schema if missing
in = "https://" + in
}
if p, err = url.Parse(in); err != nil {
// Try to parse it
return
} else if i := strings.Index(p.Path, WellKnown); i > -1 {
// Cut off well-known-path
p.Path = p.Path[:i]
}
return
}
// StaticValidateExternal
//
// Simple checks of external auth settings
func staticValidateExternal(opt options.AuthOpt) error {
if opt.ExternalRedirectURL == "" {
return fmt.Errorf("redirect URL is empty")
}
const (
tpt = "test-provider-test"
)
p, err := url.Parse(strings.Replace(opt.ExternalRedirectURL, "{provider}", tpt, 1))
if err != nil {
return fmt.Errorf("invalid redirect URL: %w", err)
}
if !strings.Contains(p.Path, tpt+"/callback") {
return fmt.Errorf("could find injected provider in the URL, make sure you use '%%s' as a placeholder: %w", err)
}
if opt.ExternalCookieSecret == "" {
return fmt.Errorf("AUTH_EXTERNAL_COOKIE_SECRET is empty")
}
if opt.SessionCookieSecure && p.Scheme != "https" {
return fmt.Errorf("session store is secure, redirect URL should have HTTPS")
}
return nil
}
// ValidateExternalRedirectURL
//
// Validates external redirect URL
func validateExternalRedirectURL(opt options.AuthOpt) error {
const tpt = "test-provider-test"
const cb = "/callback"
// Replace placeholders & remove /callback
var url = strings.Replace(opt.ExternalRedirectURL, "{provider}", tpt, 1)
url = url[0 : len(url)-len(cb)]
rsp, err := http.DefaultClient.Get(url)
if err != nil {
return fmt.Errorf("could not get response from redirect URL: %w", err)
}
defer rsp.Body.Close()
body, err := ioutil.ReadAll(rsp.Body)
if err != nil {
return fmt.Errorf("could not read response body: %w", err)
}
if strings.Contains(string(body), tpt) {
return nil
}
return fmt.Errorf("could not validate external auth redirection URL")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,66 @@
package oauth2
import (
"context"
"fmt"
"strconv"
"github.com/cortezaproject/corteza/server/pkg/handle"
"github.com/cortezaproject/corteza/server/store"
systemService "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/models"
"github.com/spf13/cast"
)
type (
// Wrapper for store to satisfy oauth2.clientStore interface
clientStore struct {
store store.AuthClients
def *types.AuthClient
}
)
var (
_ oauth2.ClientStore = &clientStore{}
)
func NewClientStore(s store.AuthClients, def *types.AuthClient) *clientStore {
return &clientStore{s, def}
}
// GetByID pulls client directly from context
//
// This requires that client is put in context before oauth2 procedures are executed!
func (cs clientStore) GetByID(ctx context.Context, id string) (_ oauth2.ClientInfo, err error) {
var c *types.AuthClient
if id == "0" || cs.def != nil && cast.ToUint64(id) == cs.def.ID {
if cs.def == nil {
return nil, fmt.Errorf("could not provide default auth client")
}
c = cs.def
} else if c, err = clientLookup(ctx, cs.store, id); err != nil {
return nil, fmt.Errorf("failed to do auth client lookup (%q): %w", id, err)
}
m := &models.Client{
ID: strconv.FormatUint(c.ID, 10),
Secret: c.Secret,
Domain: c.RedirectURI,
}
return m, nil
}
func clientLookup(ctx context.Context, s store.AuthClients, identifier interface{}) (*types.AuthClient, error) {
if id := cast.ToUint64(identifier); id > 0 {
return store.LookupAuthClientByID(ctx, s, id)
} else if h := cast.ToString(identifier); handle.IsValid(h) {
return store.LookupAuthClientByHandle(ctx, s, h)
} else {
return nil, systemService.AuthClientErrInvalidID()
}
}

View File

@@ -0,0 +1,184 @@
package oauth2
import (
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/pkg/errors"
"github.com/cortezaproject/corteza/server/pkg/id"
"github.com/cortezaproject/corteza/server/store"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/go-oauth2/oauth2/v4"
oauth2errors "github.com/go-oauth2/oauth2/v4/errors"
oauth2models "github.com/go-oauth2/oauth2/v4/models"
)
type (
tokenStorer interface {
store.AuthOa2tokens
store.AuthConfirmedClients
}
tokenStore struct {
Store tokenStorer
}
)
var (
// wrapper around time.Now() that will aid service testing
now = func() *time.Time {
c := time.Now().Round(time.Second)
return &c
}
// wrapper around id.Next() that will aid service testing
nextID = func() uint64 {
return id.Next()
}
_ oauth2.TokenStore = &tokenStore{}
)
func NewTokenStore(s tokenStorer) *tokenStore {
return &tokenStore{Store: s}
}
func (c tokenStore) Create(ctx context.Context, info oauth2.TokenInfo) (err error) {
var (
oa2t *types.AuthOa2token
acc *types.AuthConfirmedClient
userID uint64
clientID uint64
)
if clientID, err = strconv.ParseUint(info.GetClientID(), 10, 64); err != nil {
return fmt.Errorf("could not parse client ID from token info: %w", err)
}
if userID, _ = auth.ExtractFromSubClaim(info.GetUserID()); userID == 0 {
return fmt.Errorf("could not parse user ID from token info")
}
// Make oauth2 token and auth confirmation structs from user and client IDs
if oa2t, acc, err = makeAuthStructs(ctx, userID, clientID, info, info.GetCodeExpiresIn()); err != nil {
return
}
if err = store.UpsertAuthConfirmedClient(ctx, c.Store, acc); err != nil {
return
}
if err = store.CreateAuthOa2token(ctx, c.Store, oa2t); err != nil {
return
}
return nil
}
func (c tokenStore) RemoveByCode(ctx context.Context, code string) error {
return store.DeleteAuthOA2TokenByCode(ctx, c.Store, code)
}
func (c tokenStore) RemoveByAccess(ctx context.Context, access string) error {
return store.DeleteAuthOA2TokenByAccess(ctx, c.Store, access)
}
func (c tokenStore) RemoveByRefresh(ctx context.Context, refresh string) error {
return store.DeleteAuthOA2TokenByRefresh(ctx, c.Store, refresh)
}
func (c tokenStore) GetByCode(ctx context.Context, code string) (oauth2.TokenInfo, error) {
var (
internal = &oauth2models.Token{}
t, err = store.LookupAuthOa2tokenByCode(ctx, c.Store, code)
)
if err != nil {
if errors.IsNotFound(err) {
return nil, oauth2errors.ErrInvalidAuthorizeCode
}
return nil, fmt.Errorf("failed to get code: %w", err)
}
return internal, t.Data.Unmarshal(internal)
}
func (c tokenStore) GetByAccess(ctx context.Context, access string) (oauth2.TokenInfo, error) {
var (
internal = &oauth2models.Token{}
t, err = store.LookupAuthOa2tokenByAccess(ctx, c.Store, access)
)
if err != nil {
return nil, fmt.Errorf("failed to get access token: %w", err)
}
return internal, t.Data.Unmarshal(internal)
}
func (c tokenStore) GetByRefresh(ctx context.Context, refresh string) (oauth2.TokenInfo, error) {
var (
internal = &oauth2models.Token{}
t, err = store.LookupAuthOa2tokenByRefresh(ctx, c.Store, refresh)
)
if err != nil {
if errors.IsNotFound(err) {
return nil, oauth2errors.ErrInvalidRefreshToken
}
return nil, fmt.Errorf("failed to get refresh token: %w", err)
}
return internal, t.Data.Unmarshal(internal)
}
func makeAuthStructs(ctx context.Context, userID, clientID uint64, info oauth2.TokenInfo, expiresAt time.Duration) (oa2t *types.AuthOa2token, acc *types.AuthConfirmedClient, err error) {
var (
eti = auth.GetExtraReqInfoFromContext(ctx)
createdAt = time.Now().Round(time.Second)
)
oa2t = &types.AuthOa2token{
ID: id.Next(),
CreatedAt: createdAt,
RemoteAddr: eti.RemoteAddr,
UserAgent: eti.UserAgent,
ClientID: clientID,
UserID: userID,
ExpiresAt: createdAt.Add(expiresAt),
}
acc = &types.AuthConfirmedClient{
ClientID: clientID,
UserID: userID,
ConfirmedAt: createdAt,
}
if oa2t.Data, err = json.Marshal(info); err != nil {
return nil, nil, err
}
if code := info.GetCode(); code != "" {
oa2t.Code = code
} else {
// When creating non-access-code tokens,
// we need to overwrite expiration time
// with custom values for access or refresh token
oa2t.Access = info.GetAccess()
oa2t.ExpiresAt = info.GetAccessCreateAt().Add(info.GetAccessExpiresIn())
if refresh := info.GetRefresh(); refresh != "" {
oa2t.Refresh = info.GetRefresh()
oa2t.ExpiresAt = info.GetRefreshCreateAt().Add(info.GetRefreshExpiresIn())
}
}
return
}

View File

@@ -0,0 +1,135 @@
package oauth2
import (
"fmt"
"net/http"
"strings"
"github.com/cortezaproject/corteza/server/pkg/handle"
"github.com/cortezaproject/corteza/server/pkg/logger"
"github.com/cortezaproject/corteza/server/pkg/options"
"github.com/cortezaproject/corteza/server/pkg/payload"
"github.com/go-oauth2/oauth2/v4"
"github.com/go-oauth2/oauth2/v4/errors"
"github.com/go-oauth2/oauth2/v4/manage"
"github.com/go-oauth2/oauth2/v4/server"
"go.uber.org/zap"
)
const (
RedirectUriSeparator = " "
)
func NewManager(opt options.AuthOpt, log *zap.Logger, cs oauth2.ClientStore, ts oauth2.TokenStore) *manage.Manager {
manager := manage.NewDefaultManager()
// Here we are cloning the internal package variable as I do not think
// it is sane to overwrite it directly.
cfg := *manage.DefaultAuthorizeCodeTokenCfg
cfg.AccessTokenExp = opt.AccessTokenLifetime
cfg.RefreshTokenExp = opt.RefreshTokenLifetime
manager.SetAuthorizeCodeTokenCfg(&cfg)
// token store
manager.MapTokenStorage(ts)
manager.MapClientStorage(cs)
// Change the default config for it to update refresh token timestamps
// else the refresh token timestamp remains the same
//
// @note do this so we don't change the default `manage` package var
rcfg := *manage.DefaultRefreshTokenCfg
rcfg.IsResetRefreshTime = true
manager.SetRefreshTokenCfg(&rcfg)
manager.SetValidateURIHandler(func(baseURI, redirectURI string) (err error) {
if baseURI == "" {
log.Debug(
"redirect URI check for client is disabled (empty validation list)",
zap.String("sent", redirectURI),
)
return nil
}
var (
valid = strings.Split(baseURI, RedirectUriSeparator)
)
log.Debug(
"matching redirectURI",
zap.String("sent", redirectURI),
zap.Strings("valid", valid),
)
for _, baseURI = range valid {
if strings.HasPrefix(redirectURI, baseURI) {
return nil
}
}
return errors.ErrInvalidRedirectURI
})
return manager
}
func NewServer(manager oauth2.Manager) *server.Server {
srv := server.NewServer(&server.Config{
TokenType: "Bearer",
AllowGetAccessRequest: false,
AllowedResponseTypes: []oauth2.ResponseType{
oauth2.Code,
},
AllowedGrantTypes: []oauth2.GrantType{
oauth2.AuthorizationCode,
oauth2.Refreshing,
oauth2.ClientCredentials,
},
AllowedCodeChallengeMethods: []oauth2.CodeChallengeMethod{
oauth2.CodeChallengePlain,
oauth2.CodeChallengeS256,
},
}, manager)
srv.ClientInfoHandler = func(r *http.Request) (clientID, clientSecret string, err error) {
// check in basic handler first
clientID, clientSecret, err = server.ClientBasicHandler(r)
if clientID == "" && clientSecret == "" {
//error or no error, if ID & secret are empty,
// check the form handler
clientID, clientSecret, err = server.ClientFormHandler(r)
}
// just in case, when client's handle is used instead of the ID
// preload it here
if id := payload.ParseUint64(clientID); id == 0 && handle.IsValid(clientID) {
var client oauth2.ClientInfo
client, err = manager.GetClient(r.Context(), clientID)
if err != nil {
err = fmt.Errorf("could not resolve client info: %v", err)
return
}
clientID = client.GetID()
}
return
}
srv.SetInternalErrorHandler(func(err error) (re *errors.Response) {
return errors.NewResponse(err, 500)
})
srv.SetResponseErrorHandler(func(re *errors.Response) {
msg := re.Description
if msg == "" {
msg = re.Error.Error()
}
logger.Default().Warn(msg)
})
return srv
}

View File

@@ -0,0 +1,78 @@
package oauth2
import (
"fmt"
"net/http"
"github.com/cortezaproject/corteza/server/auth/request"
internalAuth "github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/pkg/payload"
"github.com/go-oauth2/oauth2/v4/server"
)
func NewUserAuthorizer(sm *request.SessionManager, loginURL, clientAuthURL string) server.UserAuthorizationHandler {
return func(w http.ResponseWriter, r *http.Request) (identity string, err error) {
var (
ses = sm.Get(r)
au = request.GetAuthUser(ses)
client = request.GetOauth2Client(ses)
)
// temporary break oauth2 flow by redirecting to
// login form and ask user to authenticate
request.SetOauth2AuthParams(ses, r.Form)
// make sure session is saved!
sm.Save(w, r)
// @todo harden security by enforcing login
// for each new authorization flow
if au == nil {
// user is currently not logged-in;
http.Redirect(w, r, loginURL, http.StatusSeeOther)
return
} else {
if !request.IsOauth2ClientAuthorized(ses) || client == nil {
// user logged in but we need to re-authenticate the client
http.Redirect(w, r, clientAuthURL, http.StatusSeeOther)
return
}
}
roles := au.User.Roles()
if client.Security != nil {
// filter user's roles with client security settings
roles = internalAuth.ApplyRoleSecurity(
payload.ParseUint64s(client.Security.PermittedRoles),
payload.ParseUint64s(client.Security.ProhibitedRoles),
payload.ParseUint64s(client.Security.ForcedRoles),
roles...,
)
}
// User authenticated, client authorized!
// remove authorization values from session
request.SetOauth2AuthParams(ses, nil)
request.SetOauth2Client(ses, nil)
request.SetOauth2ClientAuthorized(ses, false)
// make sure session is saved!
sm.Save(w, r)
return UserIDSerializer(au.User.ID, roles...), nil
}
}
// UserIDSerializer packs user ID and IDs of all roles
// into space delimited string
//
// Main reason to do this is to simplify JWT claims encoding;
// we do not have access to user's membership info from there)
func UserIDSerializer(userID uint64, rr ...uint64) string {
identity := fmt.Sprintf("%d", userID)
for _, r := range rr {
identity += fmt.Sprintf(" %d", r)
}
return identity
}

View File

@@ -0,0 +1,169 @@
package request
import (
"encoding/gob"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/gorilla/sessions"
)
type (
// handles authentication state, keeps info about permanent login and
// status of the MFA
authUser struct {
User *types.User
// is user logged-in with "remember-me"?
PermSession bool
MFAStatus map[authType]authStatus
}
authStatus uint
authType string
)
const (
// not enforced by user or by global settings
authStatusDisabled authStatus = iota
// when mfa is unconfigured on user but enforced globally
// this can happen on when new user registers or when global enforcement is later enabled
authStatusUnconfigured
// enforced by user or global settings, but not yet verified
authStatusPending
// verified
authStatusOK
)
const (
// password does not really have any function,
// more for demonstration purposes
authByPassword = "password"
authByEmailOTP = "email-otp"
authByTOTP = "totp"
)
func init() {
gob.Register(&authUser{})
}
func NewAuthUser(s *settings.Settings, u *types.User, perm bool) *authUser {
au := &authUser{
User: u,
PermSession: perm,
MFAStatus: make(map[authType]authStatus),
}
au.set(s, u)
return au
}
func (au *authUser) Update(s *settings.Settings, u *types.User) {
au.set(s, u)
}
func (au *authUser) set(s *settings.Settings, u *types.User) {
if u == nil || s == nil {
return
}
if u.Meta == nil {
u.Meta = &types.UserMeta{}
}
// User's MFA security policy
umsp := u.Meta.SecurityPolicy.MFA
// Global MFA security policy
gmsp := s.MultiFactor
mfaStatus := map[authType]authStatus{
authByPassword: authStatusOK,
authByEmailOTP: authStatusDisabled,
authByTOTP: authStatusDisabled,
}
// determinate mfa status for email OTP
if !gmsp.EmailOTP.Enabled {
mfaStatus[authByEmailOTP] = authStatusDisabled
} else if umsp.EnforcedEmailOTP || gmsp.EmailOTP.Enforced {
mfaStatus[authByEmailOTP] = authStatusPending
}
// determinate mfa status for TOTP
if !gmsp.TOTP.Enabled {
mfaStatus[authByTOTP] = authStatusDisabled
} else if !umsp.EnforcedTOTP && gmsp.TOTP.Enforced {
// TOTP not enforced on user but enforced globally
mfaStatus[authByTOTP] = authStatusUnconfigured
} else if umsp.EnforcedTOTP || gmsp.TOTP.Enforced {
mfaStatus[authByTOTP] = authStatusPending
}
au.MFAStatus = mfaStatus
}
func (au authUser) DisabledEmailOTP() bool {
return au.MFAStatus[authByEmailOTP] == authStatusDisabled
}
func (au authUser) PendingEmailOTP() bool {
return au.MFAStatus[authByEmailOTP] == authStatusPending
}
func (au authUser) DisabledTOTP() bool {
return au.MFAStatus[authByTOTP] == authStatusDisabled
}
func (au authUser) UnconfiguredTOTP() bool {
return au.MFAStatus[authByTOTP] == authStatusUnconfigured
}
func (au authUser) PendingTOTP() bool {
return au.MFAStatus[authByTOTP] == authStatusPending
}
// PendingMFA Returns true if any of MFAs are pending
func (au authUser) PendingMFA() bool {
for _, st := range au.MFAStatus {
if st == authStatusPending {
return true
}
}
return false
}
func (au *authUser) CompleteEmailOTP() {
au.MFAStatus[authByEmailOTP] = authStatusOK
}
func (au *authUser) CompleteTOTP() {
au.MFAStatus[authByTOTP] = authStatusOK
}
func (au *authUser) ResetTOTP() {
au.MFAStatus[authByTOTP] = authStatusUnconfigured
}
func (au *authUser) Forget(ses *sessions.Session) {
delete(ses.Values, keyAuthUser)
delete(ses.Values, keyRememberMe)
}
func (au *authUser) Save(ses *sessions.Session) {
ses.Values[keyAuthUser] = au
// explicitly save roles
ses.Values[keyRoles] = au.User.Roles()
if au.PermSession {
ses.Values[keyRememberMe] = true
} else {
delete(ses.Values, keyRememberMe)
}
}

View File

@@ -0,0 +1,136 @@
package request
import (
"context"
"html/template"
"net/http"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/gorilla/sessions"
)
type (
localeService interface {
NS(ctx context.Context, ns string) func(key string, rr ...string) string
}
// auth context simplifies auth request & response handling
AuthReq struct {
// HTTP request sent
Request *http.Request
Response http.ResponseWriter
// Sessions
Session *sessions.Session
// Authenticated user
AuthUser *authUser
// Current client (when in oauth2 flow)
Client *types.AuthClient
Locale localeService
// Redirect to
RedirectTo string
// Template to render
Template string
// Data to render with the template
Data map[string]interface{}
// handling flash alerts of all types
//
// should not be used for form errors; store them into sep. session keys
PrevAlerts, NewAlerts []Alert
// HTTP status to send
Status int
}
Alert struct {
// primary, secondary, danger, warning...
Type string
Text string
Html template.URL
}
// ExtraReqInfo serves as transport struct for additional
// request information we want to store with the oauth2 token
//
// There is effortless way to extend token info that is created inside go-oauth2 lib
// so we'll attach this struct to (request's) context with middleware (see MountHttpRoutes)
// and unpack from context when token is created in CortezaTokenStore.Create()
//
// ExtraReqInfo struct also serves as context value key!
ExtraReqInfo struct {
RemoteAddr string
UserAgent string
}
)
func (req AuthReq) Context() context.Context { return req.Request.Context() }
func (req *AuthReq) SetInternalError(err error) bool {
if err == nil {
return false
}
req.Status = http.StatusInternalServerError
req.Data["error"] = err
return true
}
func (req *AuthReq) PushAlert(text string) {
req.NewAlerts = append(req.NewAlerts, Alert{Type: "primary", Text: text})
}
func (req *AuthReq) PushDangerAlert(text string) {
req.NewAlerts = append(req.NewAlerts, Alert{Type: "danger", Text: text})
}
func (req *AuthReq) PopAlerts() []Alert {
val, has := req.Session.Values["alerts"]
if !has {
return nil
}
delete(req.Session.Values, "alerts")
return val.([]Alert)
}
func (req *AuthReq) SetAlerts(aa ...Alert) {
if len(aa) == 0 {
return
}
req.Session.Values["alerts"] = aa
}
// retrives key-value from session, stored under request-uri key
func (req *AuthReq) GetKV() map[string]string {
val, has := req.Session.Values["KV:"+req.Request.RequestURI]
if !has {
return nil
}
return val.(map[string]string)
}
// sets key-value value to session under request-uri key
func (req *AuthReq) SetKV(val map[string]string) {
if val == nil {
delete(req.Session.Values, "KV:"+req.Request.RequestURI)
} else {
req.Session.Values["KV:"+req.Request.RequestURI] = val
}
}
func (req *AuthReq) PopKV() map[string]string {
val := req.GetKV()
req.SetKV(nil)
return val
}

View File

@@ -0,0 +1,244 @@
package request
import (
"bytes"
"context"
"encoding/gob"
"fmt"
"net/http"
"strings"
"time"
"github.com/cortezaproject/corteza/server/pkg/auth"
"github.com/cortezaproject/corteza/server/pkg/options"
"github.com/cortezaproject/corteza/server/pkg/rand"
"github.com/cortezaproject/corteza/server/store"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/gorilla/securecookie"
"github.com/gorilla/sessions"
)
// cortezaSessionStore implements the session store and bridge to corteza store
type (
cortezaSessionStore struct {
// session store does not accept context on functions
// so we'll store the general one here on struct so we
// can capture termination signals..
//ctx context.Context
store store.AuthSessions
//cookies sessions.CookieStore
//stopCleanup chan bool
Codecs []securecookie.Codec
opt options.AuthOpt
}
)
var (
// wrapper around time.Now() that will aid service testing
now = func() *time.Time {
c := time.Now().Round(time.Second)
return &c
}
)
func init() {
gob.Register(map[string]string{})
}
func CortezaSessionStore(store store.AuthSessions, opt options.AuthOpt) *cortezaSessionStore {
var domain = opt.SessionCookieDomain
if strings.Contains(domain, ":") {
// do not set domain on the cookie if it contains port
domain = ""
}
return &cortezaSessionStore{
store: store,
Codecs: securecookie.CodecsFromPairs([]byte(opt.Secret)),
opt: opt,
}
}
func (s cortezaSessionStore) Get(r *http.Request, name string) (*sessions.Session, error) {
return sessions.GetRegistry(r).Get(s, name)
}
func (s cortezaSessionStore) New(r *http.Request, name string) (session *sessions.Session, err error) {
var (
domain = s.opt.SessionCookieDomain
)
if strings.Contains(domain, ":") {
// do not set domain on the cookie if it contains colon
// this most likely means it contains port
//
// browsers might misbehave
domain = ""
}
session = sessions.NewSession(s, name)
session.IsNew = true
session.Options = &sessions.Options{
Path: s.opt.SessionCookiePath,
Domain: domain,
Secure: s.opt.SessionCookieSecure,
HttpOnly: true,
}
if cook, errCookie := r.Cookie(name); errCookie == nil {
err = securecookie.DecodeMulti(name, cook.Value, &session.ID, s.Codecs...)
if err == nil {
err = s.load(r.Context(), session)
if err == nil {
session.IsNew = false
} else {
err = nil
}
}
}
return
}
func (s cortezaSessionStore) load(ctx context.Context, ses *sessions.Session) error {
cortezaSession, err := s.store.LookupAuthSessionByID(ctx, ses.ID)
if err != nil {
return err
}
if now().After(cortezaSession.ExpiresAt) {
return fmt.Errorf("session expired")
}
if err = gob.NewDecoder(bytes.NewReader(cortezaSession.Data)).Decode(&ses.Values); err != nil {
return fmt.Errorf("failed to decode session: %w", err)
}
// Store original session back for ref
ses.Values[keyOriginalSession] = cortezaSession
return nil
}
func (s cortezaSessionStore) Save(r *http.Request, w http.ResponseWriter, ses *sessions.Session) (err error) {
// Set delete if max-age is < 0
if ses.Options.MaxAge < 0 {
err = s.Delete(r.Context(), ses)
if err != nil {
return err
}
http.SetCookie(w, sessions.NewCookie(ses.Name(), "", ses.Options))
newSes, err := s.New(r, ses.Name())
if err != nil {
return err
}
ses.Options = newSes.Options
ses.IsNew = true
ses.ID = ""
ses.Values = nil
return nil
}
if len(ses.Values) == 0 && ses.IsNew {
// no values set, nothing to save
return nil
}
if ses.ID == "" || ses.IsNew {
ses.ID = string(rand.Bytes(64))
}
var (
// make a copy of options for cookie
cOpt = *ses.Options
// expiration for stored session
exp time.Time
)
if IsPermLogin(ses) {
// if permanent login, make sure max-age is recalculated
// so that it slides in the future with every new session-save
cOpt.MaxAge = int(s.opt.SessionPermLifetime / time.Second)
// recalculate expiration for the stored (db) session
exp = now().Add(s.opt.SessionPermLifetime)
} else {
// Warning!
// when session is not permanent, max-age is NOT set on the cookie
// making cookie last only for the length of the session
// recalculate expiration for the stored (db) session
// we do that even if the cookie does not have a value
exp = now().Add(s.opt.SessionLifetime)
}
if err = s.save(r.Context(), ses, exp); err != nil {
return err
}
// Keep the session ID key in a cookie so that it can be looked up in DB later.
encoded, err := securecookie.EncodeMulti(ses.Name(), ses.ID, s.Codecs...)
if err != nil {
return err
}
http.SetCookie(w, sessions.NewCookie(ses.Name(), encoded, &cOpt))
return nil
}
// save writes encoded session.Values to a database record.
// writes to http_sessions table by default.
func (s cortezaSessionStore) save(ctx context.Context, ses *sessions.Session, expires time.Time) (err error) {
var (
buf = &bytes.Buffer{}
cortezaSession *types.AuthSession
)
// retrieve original session value
if aux, is := ses.Values[keyOriginalSession]; is {
cortezaSession = aux.(*types.AuthSession)
} else {
cortezaSession = &types.AuthSession{
ID: ses.ID,
CreatedAt: *now(),
}
// new session does not belong to anyone yet.
// retrieve user id from ses. values
if au := GetAuthUser(ses); au != nil {
cortezaSession.UserID = au.User.ID
}
extra := auth.GetExtraReqInfoFromContext(ctx)
cortezaSession.UserAgent = extra.UserAgent
cortezaSession.RemoteAddr = extra.RemoteAddr
}
cortezaSession.ExpiresAt = expires
delete(ses.Values, keyOriginalSession)
if err = gob.NewEncoder(buf).Encode(ses.Values); err != nil {
return fmt.Errorf("failed to encode session: %w", err)
}
cortezaSession.Data = buf.Bytes()
return s.store.UpsertAuthSession(ctx, cortezaSession)
}
func (s cortezaSessionStore) Delete(ctx context.Context, ses *sessions.Session) error {
if len(ses.ID) > 0 {
return s.store.DeleteAuthSessionByID(ctx, ses.ID)
}
return nil
}
var _ sessions.Store = &cortezaSessionStore{}

View File

@@ -0,0 +1,60 @@
package request
import (
"context"
"net/http"
"github.com/cortezaproject/corteza/server/pkg/options"
"github.com/cortezaproject/corteza/server/store"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/gorilla/sessions"
"go.uber.org/zap"
)
type (
SessionManager struct {
cstore store.AuthSessions
sstore sessions.Store
opt options.AuthOpt
log *zap.Logger
}
)
func NewSessionManager(store store.AuthSessions, opt options.AuthOpt, log *zap.Logger) *SessionManager {
m := &SessionManager{opt: opt, log: log}
m.cstore = store
m.sstore = CortezaSessionStore(store, opt)
return m
}
func (m SessionManager) Store() sessions.Store { return m.sstore }
func (m *SessionManager) Get(r *http.Request) *sessions.Session {
ses, _ := m.sstore.Get(r, m.opt.SessionCookieName)
return ses
}
func (m *SessionManager) Save(w http.ResponseWriter, r *http.Request) {
if err := m.Get(r).Save(r, w); err != nil {
m.log.Warn("failed to save sessions", zap.Error(err))
}
}
// Returns all users sessions
func (m *SessionManager) Search(ctx context.Context, userID uint64) (set []*types.AuthSession, err error) {
set, _, err = m.cstore.SearchAuthSessions(ctx, types.AuthSessionFilter{UserID: userID})
for i := range set {
set[i].Data = nil
}
return
}
// DeleteByUserID removes session by user ID
func (m *SessionManager) DeleteByUserID(ctx context.Context, userID uint64) (err error) {
return m.cstore.DeleteAuthSessionsByUserID(ctx, userID)
}
// DeleteByID removes session by it's ID
func (m *SessionManager) DeleteByID(ctx context.Context, sessionID string) (err error) {
return m.cstore.DeleteAuthSessionByID(ctx, sessionID)
}

View File

@@ -0,0 +1,109 @@
package request
import (
"net/url"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/gorilla/sessions"
)
const (
keyRememberMe = "remember-me"
keyOriginalSession = "originalSession"
keyAuthUser = "authUser"
keyRoles = "roles"
keyOAuth2AuthParams = "oauth2AuthParams"
keyOAuth2Client = "oauth2ClientID"
keyOAuth2ClientAuthorized = "oauth2ClientAuthorized"
)
// GetUser is wrapper to get value from session
func GetAuthUser(ses *sessions.Session) *authUser {
val, has := ses.Values[keyAuthUser]
if !has {
return nil
}
au := val.(*authUser)
if au.User != nil {
au.User.SetRoles(getRoleMemberships(ses)...)
}
return au
}
// GetRoleMemberships is wrapper to get value from session
func getRoleMemberships(ses *sessions.Session) []uint64 {
val, has := ses.Values[keyRoles]
if !has {
return nil
}
return val.([]uint64)
}
// GetOAuth2AuthParams is wrapper to get value from session
func GetOAuth2AuthParams(ses *sessions.Session) url.Values {
val, has := ses.Values[keyOAuth2AuthParams]
if !has {
return nil
}
return val.(url.Values)
}
// SetOauth2AuthParams is a session value setting wrapper for Oauth2AuthParams
func SetOauth2AuthParams(ses *sessions.Session, val url.Values) {
if val != nil {
ses.Values[keyOAuth2AuthParams] = val
} else {
delete(ses.Values, keyOAuth2AuthParams)
}
}
// GetOauth2Client is wrapper to get value from session
func GetOauth2Client(ses *sessions.Session) *types.AuthClient {
val, has := ses.Values[keyOAuth2Client]
if !has {
return nil
}
return val.(*types.AuthClient)
}
// SetOauth2Client is a session value setting wrapper for Oauth2Client
func SetOauth2Client(ses *sessions.Session, val *types.AuthClient) {
if val != nil {
ses.Values[keyOAuth2Client] = val
} else {
delete(ses.Values, keyOAuth2Client)
}
}
// IsOauth2ClientAuthorized is wrapper to get value from session
func IsOauth2ClientAuthorized(ses *sessions.Session) bool {
val, has := ses.Values[keyOAuth2ClientAuthorized]
if !has {
return false
}
return val.(bool)
}
// SetOauth2ClientAuthorized is a session value setting wrapper for Oauth2ClientAuthorized
func SetOauth2ClientAuthorized(ses *sessions.Session, val bool) {
if val {
ses.Values[keyOAuth2ClientAuthorized] = true
} else {
delete(ses.Values, keyOAuth2ClientAuthorized)
}
}
// IsPermLogin decodes remember-me flag from the session and returns true if set
func IsPermLogin(ses *sessions.Session) (p bool) {
if aux, has := ses.Values[keyRememberMe]; has {
p, _ = aux.(bool)
}
return
}

9
server/auth/saml/idp.go Normal file
View File

@@ -0,0 +1,9 @@
package saml
type (
IdpIdentityPayload struct {
Name string
Handle string
Identifier string
}
)

57
server/auth/saml/saml.go Normal file
View File

@@ -0,0 +1,57 @@
package saml
import (
"context"
"net/http"
"net/url"
"github.com/cortezaproject/corteza/server/auth/settings"
"github.com/cortezaproject/corteza/server/system/types"
"github.com/crewjam/saml"
"github.com/crewjam/saml/samlsp"
)
type (
templateProvider struct {
Label, Handle, Icon string
}
)
// FetchIDPMetadata loads the idp metadata, usually the url
// is configured in settings
func FetchIDPMetadata(ctx context.Context, u url.URL) (*saml.EntityDescriptor, error) {
return samlsp.FetchMetadata(ctx, http.DefaultClient, u)
}
// TemplateProvider adds a wrapper to the button
// data that is displayed on the login form
func TemplateProvider(url, name string) templateProvider {
if name == "" {
name = url
}
return templateProvider{
Label: name,
Handle: "saml/init",
Icon: "key",
}
}
// UpdateSettings applies the app settings to the
// auth specific settings
func UpdateSettings(source *types.AppSettings, dest *settings.Settings) {
saml := source.Auth.External.Saml
dest.Saml.Enabled = saml.Enabled
dest.Saml.Name = saml.Name
dest.Saml.Cert = saml.Cert
dest.Saml.Key = saml.Key
dest.Saml.SignRequests = saml.SignRequests
dest.Saml.SignMethod = saml.SignMethod
dest.Saml.Binding = saml.Binding
dest.Saml.IDP.URL = saml.IDP.URL
dest.Saml.IDP.IdentName = saml.IDP.IdentName
dest.Saml.IDP.IdentHandle = saml.IDP.IdentHandle
dest.Saml.IDP.IdentIdentifier = saml.IDP.IdentIdentifier
}

View File

@@ -0,0 +1,46 @@
package saml
import (
"testing"
"github.com/stretchr/testify/require"
)
func Test_TemplateProvider(t *testing.T) {
var (
tcc = []struct {
name string
url string
expect templateProvider
}{
{
name: "Default SAML provider",
url: "http://example.tld",
expect: templateProvider{
Label: "Default SAML provider",
Handle: "saml/init",
Icon: "key",
},
},
{
name: "",
url: "http://example.tld",
expect: templateProvider{
Label: "http://example.tld",
Handle: "saml/init",
Icon: "key",
},
},
}
)
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
var (
req = require.New(t)
)
req.Equal(tc.expect, TemplateProvider(tc.url, tc.name))
})
}
}

161
server/auth/saml/sp.go Normal file
View File

@@ -0,0 +1,161 @@
package saml
import (
"crypto/rsa"
"crypto/x509"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/crewjam/saml"
"github.com/crewjam/saml/samlsp"
"go.uber.org/zap"
)
const defaultNameIdentifier = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
type (
SamlSPService struct {
log *zap.Logger
Enabled bool
IdpURL url.URL
Host url.URL
IDPUserMeta *IdpIdentityPayload
IDPMeta *saml.EntityDescriptor
sp saml.ServiceProvider
handler *samlsp.Middleware
}
SamlSPArgs struct {
Enabled bool
AcsURL string
MetaURL string
SloURL string
SignRequests bool
SignatureMethod string
Binding string
// user meta from idp
IdentityPayload IdpIdentityPayload
IdpURL url.URL
Host url.URL
Certificate *x509.Certificate
PrivateKey *rsa.PrivateKey
IdpMeta *saml.EntityDescriptor
}
)
// NewSamlSPService loads the certificates and registers the
// already fetched IDP metadata into the SAML middleware
func NewSamlSPService(log *zap.Logger, args SamlSPArgs) (s *SamlSPService, err error) {
metadataURL, _ := url.Parse(args.MetaURL)
acsURL, _ := url.Parse(args.AcsURL)
logoutURL, _ := url.Parse(args.SloURL)
sp := saml.ServiceProvider{
Key: args.PrivateKey,
Certificate: args.Certificate,
IDPMetadata: args.IdpMeta,
MetadataURL: *args.Host.ResolveReference(metadataURL),
AcsURL: *args.Host.ResolveReference(acsURL),
SloURL: *args.Host.ResolveReference(logoutURL),
}
if args.SignRequests {
sp.SignatureMethod = args.SignatureMethod
}
// default to GET
if args.Binding == "" {
args.Binding = saml.HTTPRedirectBinding
}
opts := samlsp.Options{
URL: args.Host,
Key: sp.Key,
Certificate: sp.Certificate,
IDPMetadata: args.IdpMeta,
}
// internal samlsp service
handler, err := samlsp.New(opts)
if err != nil {
err = fmt.Errorf("could not init SAML SP handler: %w", err)
return
}
if log != nil {
handler.OnError = func(w http.ResponseWriter, r *http.Request, err error) {
log.Error("error", zap.Error(err))
samlsp.DefaultOnError(w, r, err)
}
}
handler.RequestTracker = samlsp.DefaultRequestTracker(opts, &handler.ServiceProvider)
handler.ServiceProvider = sp
handler.Binding = args.Binding
s = &SamlSPService{
log: log,
Enabled: args.Enabled,
sp: sp,
handler: handler,
IdpURL: args.IdpURL,
Host: args.Host,
IDPUserMeta: &args.IdentityPayload,
}
return
}
func (ssp *SamlSPService) NameIdentifier() string {
return strings.TrimPrefix(defaultNameIdentifier, "urn:oasis:names:tc:SAML:1.1:nameid-format:")
}
// GuessIdentifier tries to guess the necessary (email) key
// for external authentication
func (ssp *SamlSPService) GuessIdentifier(payload map[string][]string) string {
tryValues := []string{
ssp.IDPUserMeta.Identifier,
ssp.NameIdentifier(),
defaultNameIdentifier,
"urn:oasis:names:tc:SAML:attribute:subject-id",
"email",
"mail",
}
for _, v := range tryValues {
if _, ok := payload[v]; ok {
return payload[v][0]
}
}
return ""
}
func (ssp *SamlSPService) Handler() *samlsp.Middleware {
return ssp.handler
}
// ServeHTTP enables us to use the service directly
// in the router
func (ssp *SamlSPService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if ssp.handler == nil {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
ssp.handler.ServeHTTP(w, r)
}

View File

@@ -0,0 +1,71 @@
package saml
import (
"testing"
"github.com/stretchr/testify/require"
)
const (
defaultSAMLEmailPayload = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
defaultSamlTestEmailPayload = "urn:oasis:names:tc:SAML:attribute:subject-id"
)
func Test_guessIdentifier(t *testing.T) {
var (
sp = SamlSPService{
IDPUserMeta: &IdpIdentityPayload{
Name: "name_field",
Handle: "handle_field",
Identifier: "identifier_field",
},
}
tcc = []struct {
name string
payload map[string][]string
expect string
}{
{
name: "default email",
payload: map[string][]string{"email": {"test@example.com"}},
expect: "test@example.com",
},
{
name: "samltest.id email payload",
payload: map[string][]string{defaultSamlTestEmailPayload: {"test@example.com"}},
expect: "test@example.com",
},
{
name: "default SAML emailAddress payload",
payload: map[string][]string{defaultSAMLEmailPayload: {"test@example.com"}},
expect: "test@example.com",
},
{
name: "emailAddress payload",
payload: map[string][]string{"emailAddress": {"test@example.com"}},
expect: "test@example.com",
},
{
name: "missing email payload",
payload: map[string][]string{"non-existing-identifier": {"test@example.com"}},
expect: "",
},
{
name: "default payload as set in settings",
payload: map[string][]string{"identifier_field": {"test@example.com"}},
expect: "test@example.com",
},
}
)
for _, tc := range tcc {
t.Run(tc.name, func(t *testing.T) {
var (
req = require.New(t)
)
req.Equal(tc.expect, sp.GuessIdentifier(tc.payload))
})
}
}

View File

@@ -0,0 +1,104 @@
package settings
type (
Settings struct {
LocalEnabled bool
SignupEnabled bool
EmailConfirmationRequired bool
PasswordResetEnabled bool
PasswordCreateEnabled bool
ExternalEnabled bool
SplitCredentialsCheck bool
Providers []Provider
Saml SAML
MultiFactor MultiFactor
BackgroundUI BackgroundUI
ProfileAvatarEnabled bool
SendUserInviteEmail bool
AutoLogoutEnabled bool
AutoLogoutTimeout int
}
SAML struct {
Enabled bool
// IdP name used on a login form
Name string
// SAML certificate
Cert string
// SAML certificate private key
Key string
// Sign AuthNRequest and assertion
SignRequests bool
// Signature method for signing
SignMethod string
// Post or redirect binding
Binding string
// Identity provider hostname
IDP struct {
URL string
// identifier payload from idp
IdentName string
IdentHandle string
IdentIdentifier string
}
}
MultiFactor struct {
EmailOTP EmailOTP
TOTP TOTP
}
EmailOTP struct {
// Can users use email for MFA
Enabled bool
// Is MFA with email enforced?
Enforced bool
}
TOTP struct {
// Can users use TOTP MFA?
Enabled bool
// Is TOTP MFA enforced?
Enforced bool
// TOTP issuer
Issuer string
}
Provider struct {
Handle string
Label string
IssuerUrl string
Key string
RedirectUrl string
Secret string
Scope string
Usage []string
}
BackgroundUI struct {
BackgroundImageSrcUrl string
Styles string
}
)
func (p Provider) HasUsage(u string) bool {
for _, pu := range p.Usage {
if pu == u {
return true
}
}
return false
}

74
server/auth/templates.go Normal file
View File

@@ -0,0 +1,74 @@
package auth
import (
"embed"
"html/template"
"io"
)
//go:embed assets/templates/*.tpl
var Templates embed.FS
type (
templateLoader func(tpls *template.Template) (tpl *template.Template, err error)
templates struct {
reload bool
}
templateExecutor interface {
ExecuteTemplate(io.Writer, string, interface{}, ...func(*template.Template) *template.Template) error
}
templateReloader struct {
base *template.Template
loader templateLoader
}
templateStatic struct {
base *template.Template
}
)
func NewReloadableTemplates(base *template.Template, loader templateLoader) *templateReloader {
return &templateReloader{
base: base,
loader: loader,
}
}
// Reloads templates before every execution
//
// This is great for local development
func (t templateReloader) ExecuteTemplate(w io.Writer, name string, data interface{}, oo ...func(*template.Template) *template.Template) error {
tpl, err := t.loader(applyTemplateExecOpt(t.base, oo...))
if err != nil {
return err
}
return tpl.ExecuteTemplate(w, name, data)
}
func NewStaticTemplates(base *template.Template, loader templateLoader) (s *templateStatic, err error) {
s = &templateStatic{}
s.base, err = loader(base)
return
}
// ExecuteTemplate executes preloaded templates
func (t templateStatic) ExecuteTemplate(w io.Writer, name string, data interface{}, oo ...func(*template.Template) *template.Template) error {
return applyTemplateExecOpt(t.base, oo...).ExecuteTemplate(w, name, data)
}
// EmbeddedTemplates returns embedded templates.
func EmbeddedTemplates(t *template.Template) (tpl *template.Template, err error) {
return t.ParseFS(Templates, "assets/templates/*.html.tpl")
}
func applyTemplateExecOpt(t *template.Template, oo ...func(*template.Template) *template.Template) *template.Template {
for _, fn := range oo {
t = fn(t)
}
return t
}

26
server/auth/token.go Normal file
View File

@@ -0,0 +1,26 @@
package auth
import (
"context"
"github.com/cortezaproject/corteza/server/store"
"github.com/cortezaproject/corteza/server/system/types"
)
type (
tokenService struct {
store store.AuthOa2tokens
}
)
func (svc tokenService) SearchByUserID(ctx context.Context, userID uint64) (types.AuthOa2tokenSet, error) {
set, _, err := store.SearchAuthOa2tokens(ctx, svc.store, types.AuthOa2tokenFilter{UserID: userID})
return set, err
}
func (svc tokenService) DeleteByID(ctx context.Context, ID uint64) error {
return store.DeleteAuthOa2tokenByID(ctx, svc.store, ID)
}
func (svc tokenService) DeleteByUserID(ctx context.Context, userID uint64) error {
return store.DeleteAuthOA2TokenByUserID(ctx, svc.store, userID)
}