snapshot of https://github.com/cortezaproject/corteza.git (2024.9.x, 2026-09-18; blobs > 131072B omitted)
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
Some checks failed
SonarQube / SonarQube (push) Failing after 5m28s
E2e test on main branch push / server and client test (map[name:admin port:8080]) (push) Failing after 3m33s
Server and lib tests / Library test (js) (push) Failing after 1m36s
Server and lib tests / Library test (vue) (push) Successful in 2m24s
Server and lib tests / Web client test (admin) (push) Successful in 2m52s
Server and lib tests / Web client test (compose) (push) Successful in 3m23s
Server and lib tests / Web client test (discovery) (push) Successful in 2m49s
Server and lib tests / Web client test (one) (push) Successful in 2m52s
Server and lib tests / Web client test (privacy) (push) Successful in 2m44s
Server and lib tests / Web client test (reporter) (push) Successful in 2m56s
Server and lib tests / Web client test (workflow) (push) Successful in 3m0s
Server and lib tests / Server tests (push) Failing after 4m55s
E2e test on main branch push / notify-failure (push) Failing after 9s
Server and lib tests / Send matrix testing notification (push) Successful in 2s
Mark stale issues and pull requests / stale (push) Successful in 40s
This commit is contained in:
17
server/auth/README.adoc
Normal file
17
server/auth/README.adoc
Normal 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.
|
||||
39
server/auth/assets/public/fonts.css
Normal file
39
server/auth/assets/public/fonts.css
Normal 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;
|
||||
}
|
||||
18
server/auth/assets/public/script.js
Normal file
18
server/auth/assets/public/script.js
Normal 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();
|
||||
})
|
||||
})
|
||||
52
server/auth/assets/public/style.css
Normal file
52
server/auth/assets/public/style.css
Normal 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;
|
||||
}
|
||||
44
server/auth/assets/templates/authorized-clients.html.tpl
Normal file
44
server/auth/assets/templates/authorized-clients.html.tpl
Normal 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" . }}
|
||||
75
server/auth/assets/templates/change-password.html.tpl
Normal file
75
server/auth/assets/templates/change-password.html.tpl
Normal 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" . }}
|
||||
59
server/auth/assets/templates/create-password.html.tpl
Normal file
59
server/auth/assets/templates/create-password.html.tpl
Normal 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" . }}
|
||||
8
server/auth/assets/templates/error-internal.html.tpl
Normal file
8
server/auth/assets/templates/error-internal.html.tpl
Normal 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" . }}
|
||||
12
server/auth/assets/templates/inc_footer.html.tpl
Normal file
12
server/auth/assets/templates/inc_footer.html.tpl
Normal 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>
|
||||
56
server/auth/assets/templates/inc_header.html.tpl
Normal file
56
server/auth/assets/templates/inc_header.html.tpl
Normal 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" . }}
|
||||
56
server/auth/assets/templates/inc_nav.html.tpl
Normal file
56
server/auth/assets/templates/inc_nav.html.tpl
Normal 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>
|
||||
12
server/auth/assets/templates/inc_toasts.html.tpl
Normal file
12
server/auth/assets/templates/inc_toasts.html.tpl
Normal 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>
|
||||
56
server/auth/assets/templates/invite.html.tpl
Normal file
56
server/auth/assets/templates/invite.html.tpl
Normal 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" . }}
|
||||
123
server/auth/assets/templates/login.html.tpl
Normal file
123
server/auth/assets/templates/login.html.tpl
Normal 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" . }}
|
||||
24
server/auth/assets/templates/logout.html.tpl
Normal file
24
server/auth/assets/templates/logout.html.tpl
Normal 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" . }}
|
||||
44
server/auth/assets/templates/mfa-totp-disable.html.tpl
Normal file
44
server/auth/assets/templates/mfa-totp-disable.html.tpl
Normal 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" . }}
|
||||
83
server/auth/assets/templates/mfa-totp.html.tpl
Normal file
83
server/auth/assets/templates/mfa-totp.html.tpl
Normal 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" . }}
|
||||
106
server/auth/assets/templates/mfa.html.tpl
Normal file
106
server/auth/assets/templates/mfa.html.tpl
Normal 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" . }}
|
||||
@@ -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" . }}
|
||||
@@ -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" . }}
|
||||
@@ -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" . }}
|
||||
146
server/auth/assets/templates/profile.html.tpl
Normal file
146
server/auth/assets/templates/profile.html.tpl
Normal 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" . }}
|
||||
49
server/auth/assets/templates/request-password-reset.html.tpl
Normal file
49
server/auth/assets/templates/request-password-reset.html.tpl
Normal 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" . }}
|
||||
56
server/auth/assets/templates/reset-password.html.tpl
Normal file
56
server/auth/assets/templates/reset-password.html.tpl
Normal 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" . }}
|
||||
233
server/auth/assets/templates/scenarios.yaml
Normal file
233
server/auth/assets/templates/scenarios.yaml
Normal 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
|
||||
122
server/auth/assets/templates/security.html.tpl
Normal file
122
server/auth/assets/templates/security.html.tpl
Normal 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" . }}
|
||||
68
server/auth/assets/templates/sessions.html.tpl
Normal file
68
server/auth/assets/templates/sessions.html.tpl
Normal 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" . }}
|
||||
77
server/auth/assets/templates/signup.html.tpl
Normal file
77
server/auth/assets/templates/signup.html.tpl
Normal 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" . }}
|
||||
53
server/auth/assets/templates/template-dev.html.tpl
Normal file
53
server/auth/assets/templates/template-dev.html.tpl
Normal 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
521
server/auth/auth.go
Normal 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
31
server/auth/auth_test.go
Normal 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
47
server/auth/client.go
Normal 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()
|
||||
}
|
||||
}
|
||||
109
server/auth/commands/assets.go
Normal file
109
server/auth/commands/assets.go
Normal 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
|
||||
}
|
||||
184
server/auth/commands/commands.go
Normal file
184
server/auth/commands/commands.go
Normal 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
90
server/auth/external/auth_handler.go
vendored
Normal 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
15
server/auth/external/external.go
vendored
Normal 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
66
server/auth/external/external_test.go
vendored
Normal 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
89
server/auth/external/goth.go
vendored
Normal 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
13
server/auth/external/main_test.go
vendored
Normal 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
172
server/auth/external/nylas/nylas.go
vendored
Normal 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
56
server/auth/external/nylas/session.go
vendored
Normal 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
253
server/auth/external/register.go
vendored
Normal 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")
|
||||
}
|
||||
110
server/auth/handlers/handle_authorized-clients.go
Normal file
110
server/auth/handlers/handle_authorized-clients.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
)
|
||||
|
||||
type (
|
||||
authClients []authClient
|
||||
authClient struct {
|
||||
ID uint64
|
||||
Name string
|
||||
Description string
|
||||
ConfirmedAt time.Time
|
||||
}
|
||||
)
|
||||
|
||||
// Will sort clients - in order of creation
|
||||
var _ sort.Interface = authClients{}
|
||||
|
||||
func (set authClients) Len() int { return len(set) }
|
||||
func (set authClients) Swap(i, j int) { set[i], set[j] = set[j], set[i] }
|
||||
func (set authClients) Less(i, j int) bool {
|
||||
return strings.Compare(set[i].Name, set[i].Name) < 0
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) clientsView(req *request.AuthReq) error {
|
||||
req.Template = TmplAuthorizedClients
|
||||
|
||||
ss, err := h.getAuthorizedClients(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sort.Sort(ss)
|
||||
req.Data["authorizedClients"] = ss
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) clientsProc(req *request.AuthReq) error {
|
||||
switch {
|
||||
case len(req.Request.PostFormValue("revoke")) > 0:
|
||||
clientID, err := strconv.ParseUint(req.Request.PostFormValue("revoke"), 10, 64)
|
||||
if clientID == 0 {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = h.TokenService.DeleteByID(req.Context(), clientID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = h.ClientService.Revoke(req.Context(), req.AuthUser.User.ID, clientID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "primary",
|
||||
Text: t("authorized-clients.alerts.removed"),
|
||||
})
|
||||
}
|
||||
|
||||
req.RedirectTo = GetLinks().AuthorizedClients
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) getAuthorizedClients(req *request.AuthReq) (ss authClients, err error) {
|
||||
var (
|
||||
set types.AuthConfirmedClientSet
|
||||
client *types.AuthClient
|
||||
)
|
||||
if set, err = h.ClientService.Confirmed(req.Context(), req.AuthUser.User.ID); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ss = make(authClients, 0, len(set))
|
||||
|
||||
for i := range set {
|
||||
client, err = h.ClientService.Lookup(req.Context(), set[i].ClientID)
|
||||
if errors.IsNotFound(err) {
|
||||
err = nil
|
||||
continue
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
ac := authClient{
|
||||
ID: set[i].ClientID,
|
||||
Name: client.Handle,
|
||||
ConfirmedAt: set[i].ConfirmedAt,
|
||||
}
|
||||
|
||||
if client.Meta != nil {
|
||||
ac.Name = client.Meta.Name
|
||||
}
|
||||
|
||||
ss = append(ss, ac)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
54
server/auth/handlers/handle_change-password.go
Normal file
54
server/auth/handlers/handle_change-password.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (h *AuthHandlers) changePasswordForm(req *request.AuthReq) error {
|
||||
h.Log.Debug("showing password change form")
|
||||
req.Template = TmplChangePassword
|
||||
req.Data["form"] = req.PopKV()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) changePasswordProc(req *request.AuthReq) (err error) {
|
||||
err = h.AuthService.ChangePassword(
|
||||
auth.SetIdentityToContext(req.Context(), req.AuthUser.User),
|
||||
req.AuthUser.User.ID,
|
||||
req.Request.PostFormValue("oldPassword"),
|
||||
req.Request.PostFormValue("newPassword"),
|
||||
)
|
||||
|
||||
t := translator(req, "auth")
|
||||
|
||||
if err == nil {
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "primary",
|
||||
Text: t("change-password.alerts.password-change-success"),
|
||||
})
|
||||
|
||||
req.RedirectTo = GetLinks().Profile
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case service.AuthErrInternalLoginDisabledByConfig().Is(err),
|
||||
service.AuthErrPasswordNotSecure().Is(err),
|
||||
service.AuthErrPasswordChangeFailedForUnknownUser().Is(err),
|
||||
service.AuthErrPasswordResetFailedOldPasswordCheckFailed().Is(err):
|
||||
req.SetKV(map[string]string{
|
||||
"error": err.Error(),
|
||||
})
|
||||
req.RedirectTo = GetLinks().ChangePassword
|
||||
|
||||
h.Log.Warn("handled error", zap.Error(err))
|
||||
return nil
|
||||
|
||||
default:
|
||||
h.Log.Error("unhandled error", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
149
server/auth/handlers/handle_change-password_test.go
Normal file
149
server/auth/handlers/handle_change-password_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/auth/settings"
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_changePasswordForm_setValues(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{}
|
||||
|
||||
authService *mockAuthService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
|
||||
rq = require.New(t)
|
||||
)
|
||||
|
||||
service.CurrentSettings = &types.AppSettings{}
|
||||
service.CurrentSettings.Auth.Internal.Enabled = true
|
||||
|
||||
authSettings := &settings.Settings{}
|
||||
|
||||
authService = prepareClientAuthService()
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
payload := map[string]string{"key": "value"}
|
||||
authReq.SetKV(payload)
|
||||
|
||||
err := authHandlers.changePasswordForm(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(TmplChangePassword, authReq.Template)
|
||||
rq.Equal(payload, authReq.Data["form"])
|
||||
}
|
||||
|
||||
func Test_changePasswordProc(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{}
|
||||
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
)
|
||||
service.CurrentSettings = &types.AppSettings{}
|
||||
|
||||
tcc := []testingExpect{
|
||||
{
|
||||
name: "successful password change",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert{{Type: "primary", Text: "change-password.alerts.password-change-success", Html: ""}},
|
||||
link: GetLinks().Profile,
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
changePassword: func(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "provided password is not secure",
|
||||
payload: map[string]string{"error": "provided password is not secure; use longer password to follow the password policy"},
|
||||
link: GetLinks().ChangePassword,
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
changePassword: func(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) {
|
||||
return service.AuthErrPasswordNotSecure()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "internal login is not enabled",
|
||||
payload: map[string]string{"error": "internal login (username/password) is disabled"},
|
||||
link: GetLinks().ChangePassword,
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
changePassword: func(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) {
|
||||
return service.AuthErrInternalLoginDisabledByConfig()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "password change failed old password does not match",
|
||||
payload: map[string]string{"error": "failed to change password, old password does not match"},
|
||||
link: GetLinks().ChangePassword,
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
changePassword: func(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) {
|
||||
return service.AuthErrPasswordResetFailedOldPasswordCheckFailed()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "password change failed for unknown user",
|
||||
payload: map[string]string{"error": "failed to change password for the unknown user"},
|
||||
link: GetLinks().ChangePassword,
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
changePassword: func(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) {
|
||||
return service.AuthErrPasswordChangeFailedForUnknownUser()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rq := require.New(t)
|
||||
|
||||
req.PostForm = url.Values{}
|
||||
|
||||
authSettings := &settings.Settings{}
|
||||
|
||||
tc.fn(authSettings)
|
||||
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
err := authHandlers.changePasswordProc(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(tc.payload, authReq.GetKV())
|
||||
|
||||
if tc.alerts != nil {
|
||||
rq.Equal(tc.alerts, authReq.NewAlerts)
|
||||
}
|
||||
|
||||
rq.Equal(tc.link, authReq.RedirectTo)
|
||||
})
|
||||
}
|
||||
}
|
||||
117
server/auth/handlers/handle_create-password.go
Normal file
117
server/auth/handlers/handle_create-password.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
var (
|
||||
loginLink = GetLinks().Login
|
||||
profileLink = GetLinks().Profile
|
||||
createPasswordLink = GetLinks().CreatePassword
|
||||
)
|
||||
|
||||
func (h *AuthHandlers) createPasswordForm(req *request.AuthReq) (err error) {
|
||||
h.Log.Debug("password create form")
|
||||
req.Template = TmplCreatePassword
|
||||
|
||||
// user not set, expecting valid token in URL
|
||||
if token := req.Request.URL.Query().Get("token"); len(token) > 0 {
|
||||
var (
|
||||
user *types.User
|
||||
// If user has password or not
|
||||
passwordSet bool
|
||||
)
|
||||
|
||||
user, err = h.AuthService.ValidatePasswordCreateToken(req.Context(), token)
|
||||
if err == nil {
|
||||
// If user does not have password (or in case there is no user with such email)
|
||||
passwordSet = h.AuthService.PasswordSet(req.Context(), user.Email)
|
||||
if !passwordSet {
|
||||
// login user
|
||||
req.AuthUser = request.NewAuthUser(h.Settings, user, false)
|
||||
|
||||
// redirect back to self (but without token and with user in session)
|
||||
h.Log.Debug("valid password create token found, refreshing page with stored user")
|
||||
req.RedirectTo = createPasswordLink
|
||||
req.AuthUser.Save(req.Session)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.AuthUser == nil || err != nil {
|
||||
h.Log.Warn("invalid password create token request", zap.Error(err))
|
||||
h.invalidPasswordCreateAlert(req, loginLink)
|
||||
return nil
|
||||
}
|
||||
|
||||
req.Data["form"] = req.PopKV()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) createPasswordProc(req *request.AuthReq) (err error) {
|
||||
h.Log.Debug("password create proc")
|
||||
|
||||
err = h.AuthService.SetPassword(
|
||||
auth.SetIdentityToContext(req.Context(), req.AuthUser.User),
|
||||
req.AuthUser.User.ID,
|
||||
req.Request.PostFormValue("password"),
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "primary",
|
||||
Text: t("create-password.alerts.password-create-success"),
|
||||
})
|
||||
|
||||
req.RedirectTo = profileLink
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case service.AuthErrPasswordCreateDisabledByConfig().Is(err):
|
||||
h.passwordCreateDisabledAlert(req)
|
||||
|
||||
h.Log.Warn("handled error", zap.Error(err))
|
||||
return nil
|
||||
|
||||
default:
|
||||
h.Log.Error("unhandled error", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) onlyIfPasswordCreateEnabled(fn handlerFn) handlerFn {
|
||||
return func(req *request.AuthReq) error {
|
||||
if !h.Settings.PasswordCreateEnabled || !h.Settings.LocalEnabled {
|
||||
h.passwordCreateDisabledAlert(req)
|
||||
return nil
|
||||
}
|
||||
|
||||
return fn(req)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) passwordCreateDisabledAlert(req *request.AuthReq) {
|
||||
req.RedirectTo = loginLink
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "danger",
|
||||
Text: t("create-password.alert.password-create-disabled"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) invalidPasswordCreateAlert(req *request.AuthReq, redirectTo string) {
|
||||
req.RedirectTo = redirectTo
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "warning",
|
||||
Text: t("create-password.alerts.invalid-expired-password-token"),
|
||||
})
|
||||
}
|
||||
181
server/auth/handlers/handle_create-password_test.go
Normal file
181
server/auth/handlers/handle_create-password_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/auth/settings"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_createPasswordForm(t *testing.T) {
|
||||
var (
|
||||
req = &http.Request{
|
||||
URL: &url.URL{},
|
||||
}
|
||||
|
||||
user *types.User
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
)
|
||||
|
||||
tcc := []testingExpect{
|
||||
{
|
||||
name: "password create success",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().CreatePassword,
|
||||
template: TmplCreatePassword,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.URL = &url.URL{RawQuery: "token=NOT_EMPTY"}
|
||||
|
||||
authService = &authServiceMocked{
|
||||
passwordSet: func(ctx context.Context, s string) bool {
|
||||
return false
|
||||
},
|
||||
validatePasswordCreateToken: func(ctx context.Context, token string) (user *types.User, err error) {
|
||||
u := makeMockUser()
|
||||
u.SetRoles()
|
||||
|
||||
return u, nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid password create token",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert{{Type: "warning", Text: "create-password.alerts.invalid-expired-password-token"}},
|
||||
link: GetLinks().Login,
|
||||
template: TmplCreatePassword,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.URL = &url.URL{RawQuery: "token=NOT_EMPTY"}
|
||||
|
||||
authService = &authServiceMocked{
|
||||
passwordSet: func(ctx context.Context, s string) bool {
|
||||
return false
|
||||
},
|
||||
validatePasswordCreateToken: func(ctx context.Context, token string) (user *types.User, err error) {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid password create request",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert{{Type: "warning", Text: "create-password.alerts.invalid-expired-password-token"}},
|
||||
link: GetLinks().Login,
|
||||
template: TmplCreatePassword,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.URL = &url.URL{RawQuery: "token=NOT_EMPTY"}
|
||||
user = makeMockUser()
|
||||
authService = &authServiceMocked{
|
||||
passwordSet: func(ctx context.Context, s string) bool {
|
||||
return false
|
||||
},
|
||||
validatePasswordCreateToken: func(ctx context.Context, token string) (user *types.User, err error) {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rq := require.New(t)
|
||||
|
||||
// reset from previous
|
||||
req.Form = url.Values{}
|
||||
req.PostForm = url.Values{}
|
||||
|
||||
authSettings := &settings.Settings{}
|
||||
|
||||
tc.fn(authSettings)
|
||||
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
// unset so we get to the main functionality
|
||||
authReq.AuthUser = nil
|
||||
|
||||
err := authHandlers.createPasswordForm(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(tc.template, authReq.Template)
|
||||
rq.Equal(tc.payload, authReq.GetKV())
|
||||
rq.Equal(tc.alerts, authReq.NewAlerts)
|
||||
rq.Equal(tc.link, authReq.RedirectTo)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_createPasswordProc(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{}
|
||||
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
)
|
||||
|
||||
tcc := []testingExpect{
|
||||
{
|
||||
name: "create password success",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert{{Type: "primary", Text: "create-password.alerts.password-create-success", Html: ""}},
|
||||
link: GetLinks().Profile,
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
setPassword: func(ctx context.Context, userID uint64, password string) (err error) {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "create password disabled",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert{{Type: "danger", Text: "create-password.alert.password-create-disabled", Html: ""}},
|
||||
link: GetLinks().Login,
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
setPassword: func(ctx context.Context, userID uint64, password string) (err error) {
|
||||
return service.AuthErrPasswordCreateDisabledByConfig()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rq := require.New(t)
|
||||
|
||||
authSettings := &settings.Settings{}
|
||||
|
||||
tc.fn(authSettings)
|
||||
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
err := authHandlers.createPasswordProc(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(tc.payload, authReq.GetKV())
|
||||
rq.Equal(tc.alerts, authReq.NewAlerts)
|
||||
rq.Equal(tc.link, authReq.RedirectTo)
|
||||
})
|
||||
}
|
||||
}
|
||||
123
server/auth/handlers/handle_dev.go
Normal file
123
server/auth/handlers/handle_dev.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package handlers
|
||||
|
||||
// Handlers dev-view for templates & scenarios
|
||||
//
|
||||
// See auth/README.adoc for details on how this works
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/pkg/y7s"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type (
|
||||
devScenariosDoc []*devTemplate
|
||||
devTemplate struct {
|
||||
Template string
|
||||
Scenes devScenes
|
||||
}
|
||||
|
||||
devScenes []*devScene
|
||||
devScene struct {
|
||||
Name string
|
||||
Template string
|
||||
Data map[string]interface{}
|
||||
}
|
||||
)
|
||||
|
||||
func (h *AuthHandlers) devView(req *request.AuthReq) (err error) {
|
||||
req.Template = "template-dev.html.tpl"
|
||||
req.Data["templates"], err = getScenes(h.Opt.AssetsPath)
|
||||
return
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) devSceneView(w http.ResponseWriter, r *http.Request) {
|
||||
s, err := findScenario(
|
||||
h.Opt.AssetsPath,
|
||||
r.URL.Query().Get("template"),
|
||||
r.URL.Query().Get("scene"),
|
||||
)
|
||||
|
||||
s.Data["authBg"] = template.CSS(h.bgStylesData())
|
||||
|
||||
if err == nil && s != nil {
|
||||
err = h.Templates.ExecuteTemplate(w, s.Template+".html.tpl", s.Data)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func findScenario(assetsPath, template, scene string) (*devScene, error) {
|
||||
templates, err := getScenes(assetsPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, t := range templates {
|
||||
if t.Template != template {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, s := range t.Scenes {
|
||||
if s.Name != scene {
|
||||
continue
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func getScenes(assetsPath string) (devScenariosDoc, error) {
|
||||
if assetsPath == "" {
|
||||
return nil, fmt.Errorf("can not load scenarios without AUTH_ASSETS_PATH and exported assets")
|
||||
}
|
||||
|
||||
f, err := os.Open(assetsPath + "/templates/scenarios.yaml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
aux := devScenariosDoc{}
|
||||
return aux, yaml.NewDecoder(f).Decode(&aux)
|
||||
}
|
||||
|
||||
func (doc *devScenariosDoc) UnmarshalYAML(n *yaml.Node) error {
|
||||
return y7s.EachMap(n, func(k *yaml.Node, v *yaml.Node) (err error) {
|
||||
dt := &devTemplate{Template: k.Value, Scenes: devScenes{}}
|
||||
|
||||
err = y7s.EachMap(v, func(k *yaml.Node, v *yaml.Node) (err error) {
|
||||
s := &devScene{
|
||||
Name: k.Value,
|
||||
Template: dt.Template,
|
||||
Data: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
if err = v.Decode(s.Data); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
dt.Scenes = append(dt.Scenes, s)
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
*doc = append(*doc, dt)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
232
server/auth/handlers/handle_external.go
Normal file
232
server/auth/handlers/handle_external.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/external"
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/auth/settings"
|
||||
"github.com/cortezaproject/corteza/server/pkg/api"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func copyProviderToContext(r *http.Request) *http.Request {
|
||||
return r.WithContext(context.WithValue(r.Context(), "provider", chi.URLParam(r, "provider")))
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) externalInit(w http.ResponseWriter, r *http.Request) {
|
||||
r = copyProviderToContext(r)
|
||||
h.Log.Info("starting external authentication flow")
|
||||
|
||||
beginUserAuth(w, r, external.NewDefaultExternalHandler())
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) externalCallback(w http.ResponseWriter, r *http.Request) {
|
||||
r = copyProviderToContext(r)
|
||||
h.Log.Info("external authentication callback")
|
||||
|
||||
if user, err := completeUserAuth(w, r, external.NewDefaultExternalHandler()); err != nil {
|
||||
h.Log.Error("failed to complete user auth", zap.Error(err))
|
||||
h.handleFailedExternalAuth(w, r, err)
|
||||
} else {
|
||||
h.handleSuccessfulExternalAuth(w, r, *user)
|
||||
}
|
||||
}
|
||||
|
||||
// Handles authentication via external auth providers of
|
||||
// unknown an user + appending authentication on external providers
|
||||
// to a current user
|
||||
func (h *AuthHandlers) handleSuccessfulExternalAuth(w http.ResponseWriter, r *http.Request, cred types.ExternalAuthUser) {
|
||||
h.Log.Info("login successful", zap.String("provider", cred.Provider))
|
||||
|
||||
switch cred.Provider {
|
||||
case "saml":
|
||||
h.handleSuccessfulExternalSAMLAuth(w, r, cred)
|
||||
|
||||
default:
|
||||
h.handleSuccessfulExternalGenericAuth(w, r, cred)
|
||||
}
|
||||
}
|
||||
|
||||
// handleSuccessfulExternalSAMLAuth is a special handler just for the SAML authentication
|
||||
func (h *AuthHandlers) handleSuccessfulExternalSAMLAuth(w http.ResponseWriter, r *http.Request, cred types.ExternalAuthUser) {
|
||||
var (
|
||||
user *types.User
|
||||
err error
|
||||
ctx = r.Context()
|
||||
)
|
||||
|
||||
// Try to login/sign-up external user
|
||||
if user, err = h.AuthService.External(ctx, cred); err != nil {
|
||||
api.Send(w, r, err)
|
||||
return
|
||||
}
|
||||
|
||||
h.handle(func(req *request.AuthReq) error {
|
||||
req.AuthUser = request.NewAuthUser(
|
||||
h.Settings,
|
||||
user,
|
||||
|
||||
// external logins are never permanent!
|
||||
false,
|
||||
)
|
||||
|
||||
// auto-complete EmailOTP and TOTP when authenticating via external identity provider
|
||||
req.AuthUser.CompleteEmailOTP()
|
||||
req.AuthUser.CompleteTOTP()
|
||||
|
||||
req.AuthUser.Save(req.Session)
|
||||
|
||||
handleSuccessfulAuth(req)
|
||||
return nil
|
||||
})(w, r)
|
||||
}
|
||||
|
||||
// handleSuccessfulExternalGenericAuth is a generic external auth handler
|
||||
// which can be used with generic auth providers to also support external API integrations
|
||||
func (h *AuthHandlers) handleSuccessfulExternalGenericAuth(w http.ResponseWriter, r *http.Request, cred types.ExternalAuthUser) {
|
||||
var (
|
||||
user *types.User
|
||||
err error
|
||||
ctx = r.Context()
|
||||
)
|
||||
|
||||
handleErr := func(err error) {
|
||||
h.handleFailedExternalAuth(w, r, err)
|
||||
}
|
||||
|
||||
// Get the provider config so we can correctly handle the provided values
|
||||
p := h.getProviderConfig(cred.Provider, h.Settings.Providers)
|
||||
if p == nil {
|
||||
handleErr(fmt.Errorf("credentials for provider %s are not registered in the system", cred.Provider))
|
||||
return
|
||||
}
|
||||
|
||||
// For later, the request's auth user (no big deal if there isn't one)
|
||||
au := request.GetAuthUser(h.SessionManager.Get(r))
|
||||
|
||||
// Check if we're using it as an identity provider; if so, use it to authenticate
|
||||
// If no providers are specified, assume identity (legacy support)
|
||||
if len(p.Usage) == 0 || p.HasUsage(types.ExternalProviderUsageIdentity) {
|
||||
// Try to login/sign-up external user
|
||||
if user, err = h.AuthService.External(ctx, cred); err != nil {
|
||||
handleErr(err)
|
||||
return
|
||||
}
|
||||
|
||||
au = request.NewAuthUser(
|
||||
h.Settings,
|
||||
user,
|
||||
|
||||
// external logins are never permanent!
|
||||
false,
|
||||
)
|
||||
|
||||
// If that's that, cut the flow here
|
||||
if !p.HasUsage(types.ExternalProviderUsageAPI) {
|
||||
h.handle(func(req *request.AuthReq) error {
|
||||
req.AuthUser = au
|
||||
|
||||
// auto-complete EmailOTP and TOTP when authenticating via external identity provider
|
||||
req.AuthUser.CompleteEmailOTP()
|
||||
req.AuthUser.CompleteTOTP()
|
||||
|
||||
req.AuthUser.Save(req.Session)
|
||||
|
||||
handleSuccessfulAuth(req)
|
||||
return nil
|
||||
})(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we're using it for an API integration; if so, note the access tokens
|
||||
if p.HasUsage(types.ExternalProviderUsageAPI) {
|
||||
if au.User == nil {
|
||||
handleErr(fmt.Errorf("could not add credentials for user: not authenticated"))
|
||||
return
|
||||
}
|
||||
ctx = auth.SetIdentityToContext(ctx, au.User)
|
||||
|
||||
// Look for existing
|
||||
cc, err := h.CredentialsService.List(ctx, au.User.ID)
|
||||
if err != nil {
|
||||
handleErr(fmt.Errorf("couldn't fetch user credentials: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Find the existing one
|
||||
kind := fmt.Sprintf("access-%s", cred.Provider)
|
||||
var current *types.Credential
|
||||
for _, c := range cc {
|
||||
if c.Kind == kind && c.OwnerID == au.User.ID {
|
||||
current = c
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Update
|
||||
if current != nil {
|
||||
current.Credentials = cred.AccessToken
|
||||
_, err = h.CredentialsService.Update(ctx, current)
|
||||
if err != nil {
|
||||
handleErr(fmt.Errorf("couldn't update user credentials: %w", err))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
_, err = h.CredentialsService.Create(ctx, &types.Credential{
|
||||
Label: fmt.Sprintf("access token %s %s", cred.Provider, au.User.Email),
|
||||
Kind: kind,
|
||||
OwnerID: au.User.ID,
|
||||
Credentials: cred.AccessToken,
|
||||
})
|
||||
if err != nil {
|
||||
handleErr(fmt.Errorf("couldn't create user credentials: %w", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.handle(func(req *request.AuthReq) error {
|
||||
req.AuthUser = au
|
||||
|
||||
handleSuccessfulAuth(req)
|
||||
return nil
|
||||
})(w, r)
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) getProviderConfig(handle string, set []settings.Provider) *settings.Provider {
|
||||
for _, s := range set {
|
||||
if s.Handle == handle {
|
||||
return &s
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) handleFailedExternalAuth(w http.ResponseWriter, _ *http.Request, err error) {
|
||||
if strings.Contains(err.Error(), "Error processing your OAuth request: Invalid oauth_verifier parameter") {
|
||||
// Just take user through the same loop again
|
||||
w.Header().Set("Location", GetLinks().Profile)
|
||||
w.WriteHeader(http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprintf(w, "SSO Error: %v", err.Error())
|
||||
}
|
||||
|
||||
func beginUserAuth(w http.ResponseWriter, r *http.Request, eh external.ExternalAuthHandler) {
|
||||
eh.BeginUserAuth(w, r)
|
||||
}
|
||||
|
||||
func completeUserAuth(w http.ResponseWriter, r *http.Request, eh external.ExternalAuthHandler) (u *types.ExternalAuthUser, err error) {
|
||||
return eh.CompleteUserAuth(w, r)
|
||||
}
|
||||
79
server/auth/handlers/handle_invite.go
Normal file
79
server/auth/handlers/handle_invite.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (h *AuthHandlers) acceptInviteForm(req *request.AuthReq) (err error) {
|
||||
h.Log.Debug("invite email password reset form")
|
||||
|
||||
req.Template = TmplInvite
|
||||
|
||||
// user not set, expecting valid token in URL
|
||||
if token := req.Request.URL.Query().Get("token"); len(token) > 0 {
|
||||
var user *types.User
|
||||
|
||||
user, err = h.AuthService.ValidateInviteEmailToken(req.Context(), token)
|
||||
if err == nil {
|
||||
// login user
|
||||
req.AuthUser = request.NewAuthUser(h.Settings, user, false)
|
||||
|
||||
if req.AuthUser.PendingEmailOTP() {
|
||||
// Email OTP enabled & pending
|
||||
//
|
||||
// If we're here it means user clicked on a link in an email;
|
||||
// we are effectively confirming email OTP
|
||||
req.AuthUser.CompleteEmailOTP()
|
||||
}
|
||||
|
||||
// redirect back to self (but without token and with user in session)
|
||||
h.Log.Debug("valid user invite password reset token found, refreshing page with stored user")
|
||||
req.RedirectTo = GetLinks().AcceptInvite
|
||||
req.AuthUser.Save(req.Session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if req.AuthUser == nil || err != nil {
|
||||
h.Log.Warn("invalid, user invite password reset token used", zap.Error(err))
|
||||
req.RedirectTo = GetLinks().Login
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "warning",
|
||||
Text: t("invite.alert.invalid-expired-invite-token"),
|
||||
})
|
||||
}
|
||||
|
||||
req.Data["form"] = req.PopKV()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) acceptInviteProc(req *request.AuthReq) (err error) {
|
||||
h.Log.Debug("password reset proc")
|
||||
|
||||
err = h.AuthService.SetPassword(req.Context(), req.AuthUser.User.ID, req.Request.PostFormValue("password"))
|
||||
|
||||
if err == nil {
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "primary",
|
||||
Text: t("invite.alert.success"),
|
||||
})
|
||||
|
||||
req.RedirectTo = GetLinks().Profile
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case service.AuthErrPasswordResetDisabledByConfig().Is(err):
|
||||
h.passwordResetDisabledAlert(req)
|
||||
return nil
|
||||
|
||||
default:
|
||||
h.Log.Error("unhandled error", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
163
server/auth/handlers/handle_login.go
Normal file
163
server/auth/handlers/handle_login.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
provider struct {
|
||||
Label, Handle, Icon string
|
||||
}
|
||||
)
|
||||
|
||||
func (h *AuthHandlers) loginForm(req *request.AuthReq) error {
|
||||
req.Template = TmplLogin
|
||||
kv := req.PopKV()
|
||||
|
||||
if kv == nil && h.Settings.SplitCredentialsCheck {
|
||||
// Force login form to show only email input
|
||||
//
|
||||
// KV is nil (means that this is first load of the form)
|
||||
// and credentials check is split into two parts (email first then credentials)
|
||||
kv = map[string]string{
|
||||
"splitCredentialsCheck": "split",
|
||||
}
|
||||
}
|
||||
|
||||
req.Data["form"] = kv
|
||||
req.Data["enableRememberMe"] = h.Opt.SessionPermLifetime > 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) loginProc(req *request.AuthReq) (err error) {
|
||||
// In most cases, we want to redirect back to login
|
||||
req.RedirectTo = GetLinks().Login
|
||||
req.SetKV(nil)
|
||||
|
||||
var (
|
||||
user *types.User
|
||||
email = req.Request.PostFormValue("email")
|
||||
password = req.Request.PostFormValue("password")
|
||||
)
|
||||
|
||||
err = func() (err error) {
|
||||
if len(email) > 0 && len(password) == 0 && h.Settings.SplitCredentialsCheck {
|
||||
// Email provided but no password and the
|
||||
// split credentials check enabled
|
||||
//
|
||||
// SetKV w/ email will prevent the login form to show only email input
|
||||
req.SetKV(map[string]string{"email": email})
|
||||
|
||||
// If user does not have password set (or in case there is no user with such email)
|
||||
// and there is exactly one IdP we redirect user to that IdP
|
||||
if !h.AuthService.PasswordSet(req.Context(), email) && len(h.Settings.Providers) == 1 {
|
||||
// User w/o the password
|
||||
// In case there is one single IdP automatically redirect user there
|
||||
req.RedirectTo = fmt.Sprintf("%s/%s", GetLinks().External, h.Settings.Providers[0].Handle)
|
||||
|
||||
// for the clarity of the flow
|
||||
// keeping this optional return here
|
||||
return
|
||||
}
|
||||
|
||||
// This user has existing password credentials or with more than one IdP
|
||||
//
|
||||
// Take user back to the login form and ask for the password
|
||||
// or any other kind of login
|
||||
return
|
||||
}
|
||||
|
||||
user, err = h.AuthService.InternalLogin(req.Context(), email, password)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
isPerm = len(req.Request.PostFormValue("keep-session")) > 0
|
||||
lifetime = h.Opt.SessionLifetime
|
||||
)
|
||||
|
||||
if isPerm {
|
||||
lifetime = h.Opt.SessionPermLifetime
|
||||
}
|
||||
|
||||
req.AuthUser = request.NewAuthUser(h.Settings, user, isPerm)
|
||||
|
||||
req.AuthUser.Save(req.Session)
|
||||
|
||||
h.Log.Info(
|
||||
"login with password successful",
|
||||
zap.Any("mfa", req.AuthUser.MFAStatus),
|
||||
zap.Bool("perm-login", isPerm),
|
||||
zap.Duration("lifetime", lifetime),
|
||||
)
|
||||
|
||||
if req.AuthUser.PendingEmailOTP() {
|
||||
// Email OTP enforced (globally or by user sec. policy)
|
||||
//
|
||||
// @todo this should probably be part of login in auth service?
|
||||
if err = h.AuthService.SendEmailOTP(auth.SetIdentityToContext(req.Context(), req.AuthUser.User)); err != nil {
|
||||
return errors.Internal("could not send OTP via email, contact your administrator").Wrap(err)
|
||||
}
|
||||
}
|
||||
|
||||
handleSuccessfulAuth(req)
|
||||
|
||||
return
|
||||
}()
|
||||
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case service.AuthErrInternalLoginDisabledByConfig().Is(err):
|
||||
h.localDisabledAlert(req)
|
||||
return nil
|
||||
case service.AuthErrInvalidEmailFormat().Is(err),
|
||||
service.AuthErrInvalidCredentials().Is(err),
|
||||
service.AuthErrCredentialsLinkedToInvalidUser().Is(err):
|
||||
req.SetKV(map[string]string{
|
||||
"error": err.Error(),
|
||||
"email": email,
|
||||
})
|
||||
|
||||
h.Log.Warn("handled error", zap.Error(err))
|
||||
return nil
|
||||
|
||||
default:
|
||||
h.Log.Error("unhandled error", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) onlyIfLocalEnabled(fn handlerFn) handlerFn {
|
||||
return func(req *request.AuthReq) error {
|
||||
if !h.Settings.LocalEnabled {
|
||||
h.localDisabledAlert(req)
|
||||
return nil
|
||||
}
|
||||
|
||||
return fn(req)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) localDisabledAlert(req *request.AuthReq) {
|
||||
if req.AuthUser.User != nil {
|
||||
req.RedirectTo = GetLinks().Profile
|
||||
} else {
|
||||
req.RedirectTo = GetLinks().Login
|
||||
}
|
||||
|
||||
t := translator(req, "auth")
|
||||
|
||||
req.PushDangerAlert(t("login.alert.local-disabled"))
|
||||
}
|
||||
214
server/auth/handlers/handle_login_test.go
Normal file
214
server/auth/handlers/handle_login_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/auth/settings"
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type (
|
||||
expectPayload struct {
|
||||
kv map[string]string
|
||||
alert []request.Alert
|
||||
}
|
||||
)
|
||||
|
||||
func Test_loginForm_setValues(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{}
|
||||
|
||||
authService *mockAuthService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
|
||||
rq = require.New(t)
|
||||
)
|
||||
|
||||
service.CurrentSettings = &types.AppSettings{}
|
||||
service.CurrentSettings.Auth.Internal.Enabled = true
|
||||
|
||||
authSettings := &settings.Settings{}
|
||||
|
||||
authService = prepareClientAuthService()
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
payload := map[string]string{"key": "value"}
|
||||
authReq.SetKV(payload)
|
||||
|
||||
authHandlers.Settings = &settings.Settings{
|
||||
EmailConfirmationRequired: true,
|
||||
}
|
||||
|
||||
err := authHandlers.loginForm(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(TmplLogin, authReq.Template)
|
||||
rq.Equal(payload, authReq.Data["form"])
|
||||
}
|
||||
|
||||
func Test_loginProc(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{}
|
||||
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
)
|
||||
|
||||
service.CurrentSettings = &types.AppSettings{}
|
||||
|
||||
tcc := []testingExpect{
|
||||
{
|
||||
name: "successful login",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Profile,
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
internalLogin: func(ctx context.Context, email, password string) (u *types.User, err error) {
|
||||
u = &types.User{Meta: &types.UserMeta{}}
|
||||
u.Meta.SecurityPolicy.MFA.EnforcedEmailOTP = false
|
||||
u.Meta.SecurityPolicy.MFA.EnforcedTOTP = false
|
||||
|
||||
err = nil
|
||||
return
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "internal login is not enabled",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert{{Type: "danger", Text: "login.alert.local-disabled", Html: ""}},
|
||||
link: GetLinks().Profile,
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
internalLogin: func(ctx context.Context, email, password string) (u *types.User, err error) {
|
||||
err = service.AuthErrInternalLoginDisabledByConfig()
|
||||
return
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid email format",
|
||||
payload: map[string]string{"email": "email@", "error": "invalid email"},
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Login,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.PostForm.Add("email", "email@")
|
||||
|
||||
authService = &authServiceMocked{
|
||||
internalLogin: func(ctx context.Context, email, password string) (u *types.User, err error) {
|
||||
err = service.AuthErrInvalidEmailFormat()
|
||||
return
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid credentials",
|
||||
payload: map[string]string{"email": "mockuser@example.tld", "error": "invalid username and password combination"},
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Login,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.PostForm.Add("email", "mockuser@example.tld")
|
||||
|
||||
authService = &authServiceMocked{
|
||||
internalLogin: func(ctx context.Context, email, password string) (u *types.User, err error) {
|
||||
err = service.AuthErrInvalidCredentials()
|
||||
return
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "credentials linked to invalid user",
|
||||
payload: map[string]string{"email": "mockuser@example.tld", "error": "credentials {{credentials.kind}} linked to disabled or deleted user {{user}}"},
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Login,
|
||||
fn: func(*settings.Settings) {
|
||||
req.PostForm.Add("email", "mockuser@example.tld")
|
||||
|
||||
authService = &authServiceMocked{
|
||||
internalLogin: func(ctx context.Context, email, password string) (u *types.User, err error) {
|
||||
err = service.AuthErrCredentialsLinkedToInvalidUser()
|
||||
return
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "split credentials check",
|
||||
payload: map[string]string{"email": "mockuser@example.tld"},
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Login,
|
||||
fn: func(authSettings *settings.Settings) {
|
||||
req.PostForm.Add("email", "mockuser@example.tld")
|
||||
authSettings.SplitCredentialsCheck = true
|
||||
|
||||
authService = &authServiceMocked{
|
||||
passwordSet: func(ctx context.Context, email string) bool {
|
||||
return false
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "split credentials check with providers",
|
||||
payload: map[string]string{"email": "mockuser@example.tld"},
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().External + "/test-idp",
|
||||
fn: func(authSettings *settings.Settings) {
|
||||
req.PostForm.Add("email", "mockuser@example.tld")
|
||||
authSettings.SplitCredentialsCheck = true
|
||||
authSettings.Providers = []settings.Provider{
|
||||
{Handle: "test-idp"},
|
||||
}
|
||||
|
||||
authService = &authServiceMocked{
|
||||
passwordSet: func(ctx context.Context, email string) bool {
|
||||
return false
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rq := require.New(t)
|
||||
|
||||
// reset from previous
|
||||
req.Form = url.Values{}
|
||||
req.PostForm = url.Values{}
|
||||
user.Meta = &types.UserMeta{}
|
||||
|
||||
authSettings := &settings.Settings{}
|
||||
|
||||
tc.fn(authSettings)
|
||||
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
err := authHandlers.loginProc(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(tc.payload, authReq.GetKV())
|
||||
rq.Equal(tc.alerts, authReq.NewAlerts)
|
||||
rq.Equal(tc.link, authReq.RedirectTo)
|
||||
})
|
||||
}
|
||||
}
|
||||
32
server/auth/handlers/handle_logout.go
Normal file
32
server/auth/handlers/handle_logout.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/markbates/goth/gothic"
|
||||
)
|
||||
|
||||
func (h *AuthHandlers) logoutProc(req *request.AuthReq) (err error) {
|
||||
req.Session.Options.MaxAge = -1
|
||||
if err = req.Session.Save(req.Request, req.Response); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = gothic.Logout(req.Response, req.Request); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent these two to be rendered by in the template
|
||||
req.AuthUser = nil
|
||||
req.Client = nil
|
||||
h.Log.Info("logout successful")
|
||||
|
||||
req.Template = TmplLogout
|
||||
|
||||
req.Data["link"] = GetLinks().Login
|
||||
|
||||
if bl := req.Request.FormValue("back"); bl != "" {
|
||||
req.Data["link"] = sanitizeLink(bl)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
48
server/auth/handlers/handle_logout_test.go
Normal file
48
server/auth/handlers/handle_logout_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/auth/settings"
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_logoutProc(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{}
|
||||
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
|
||||
authSettings = &settings.Settings{}
|
||||
|
||||
rq = require.New(t)
|
||||
)
|
||||
|
||||
service.CurrentSettings = &types.AppSettings{}
|
||||
service.CurrentSettings.Auth.Internal.Enabled = true
|
||||
|
||||
authService = &authServiceMocked{}
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
req.PostForm = url.Values{}
|
||||
req.PostForm.Add("back", "\"><script>alert(origin)</script><\"")
|
||||
authReq.Session.Values = map[interface{}]interface{}{"key": url.Values{"key": []string{"value"}}}
|
||||
|
||||
err := authHandlers.logoutProc(authReq)
|
||||
rq.NoError(err)
|
||||
rq.Empty(authReq.Session.Values)
|
||||
rq.Empty(authReq.AuthUser)
|
||||
rq.Empty(authReq.Client)
|
||||
rq.Equal("//scriptalert(origin)/script", authReq.Data["link"])
|
||||
rq.Equal(TmplLogout, authReq.Template)
|
||||
}
|
||||
87
server/auth/handlers/handle_mfa.go
Normal file
87
server/auth/handlers/handle_mfa.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
)
|
||||
|
||||
// Handles MFA TOTP configuration form
|
||||
//
|
||||
// Where the TOTP QR & code are displayed and where
|
||||
func (h *AuthHandlers) mfaForm(req *request.AuthReq) (err error) {
|
||||
req.Template = TmplMfa
|
||||
|
||||
if !req.AuthUser.PendingMFA() {
|
||||
req.RedirectTo = GetLinks().Profile
|
||||
return nil
|
||||
}
|
||||
|
||||
if req.Request.URL.Query().Get("action") == "resendEmailOtp" {
|
||||
err = h.AuthService.SendEmailOTP(
|
||||
auth.SetIdentityToContext(req.Context(), req.AuthUser.User),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
req.SetKV(map[string]string{"emailOtpError": err.Error()})
|
||||
return nil
|
||||
}
|
||||
|
||||
t := translator(req, "auth")
|
||||
|
||||
req.PushAlert(t("mfa.alerts.email.resent"))
|
||||
req.RedirectTo = GetLinks().Mfa
|
||||
}
|
||||
|
||||
req.Data["form"] = req.PopKV()
|
||||
req.Data["emailOtpDisabled"] = req.AuthUser.DisabledEmailOTP()
|
||||
req.Data["emailOtpPending"] = req.AuthUser.PendingEmailOTP()
|
||||
req.Data["totpDisabled"] = req.AuthUser.DisabledTOTP()
|
||||
req.Data["totpPending"] = req.AuthUser.PendingTOTP()
|
||||
req.Data["totpUnconfigured"] = req.AuthUser.UnconfiguredTOTP()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handles MFA OTP form processing
|
||||
func (h *AuthHandlers) mfaProc(req *request.AuthReq) (err error) {
|
||||
req.RedirectTo = GetLinks().Mfa
|
||||
req.SetKV(nil)
|
||||
|
||||
switch req.Request.Form.Get("action") {
|
||||
case "verifyEmailOtp":
|
||||
err = h.AuthService.ValidateEmailOTP(
|
||||
auth.SetIdentityToContext(req.Context(), req.AuthUser.User),
|
||||
req.Request.PostFormValue("code"),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
req.SetKV(map[string]string{"emailOtpError": err.Error()})
|
||||
return nil
|
||||
}
|
||||
|
||||
t := translator(req, "auth")
|
||||
|
||||
req.PushAlert(t("mfa.alerts.email.confirmed"))
|
||||
req.AuthUser.CompleteEmailOTP()
|
||||
|
||||
case "verifyTotp":
|
||||
err = h.AuthService.ValidateTOTP(
|
||||
auth.SetIdentityToContext(req.Context(), req.AuthUser.User),
|
||||
req.Request.PostFormValue("code"),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
req.SetKV(map[string]string{"totpError": err.Error()})
|
||||
return nil
|
||||
}
|
||||
|
||||
t := translator(req, "auth")
|
||||
|
||||
req.PushAlert(t("mfa.alerts.totp.confirmed"))
|
||||
req.AuthUser.CompleteTOTP()
|
||||
}
|
||||
|
||||
// All required MFA's confirmed, proceed to profile
|
||||
handleSuccessfulAuth(req)
|
||||
|
||||
return nil
|
||||
}
|
||||
212
server/auth/handlers/handle_mfa_test.go
Normal file
212
server/auth/handlers/handle_mfa_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/auth/settings"
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_mfaProc(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{}
|
||||
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
)
|
||||
|
||||
service.CurrentSettings = &types.AppSettings{}
|
||||
|
||||
tcc := []testingExpect{
|
||||
{
|
||||
name: "Email: successful login",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert{{Type: "primary", Text: "mfa.alerts.email.confirmed"}},
|
||||
link: GetLinks().Profile,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.Form.Set("action", "verifyEmailOtp")
|
||||
req.PostForm.Add("code", "123456")
|
||||
|
||||
authService = &authServiceMocked{
|
||||
validateEmailOTP: func(ctx context.Context, code string) (err error) {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TOTP: successful login",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert{{Type: "primary", Text: "mfa.alerts.totp.confirmed"}},
|
||||
link: GetLinks().Mfa,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.Form.Set("action", "verifyTotp")
|
||||
req.PostForm.Add("code", "123456")
|
||||
|
||||
authService = &authServiceMocked{
|
||||
validateTOTP: func(ctx context.Context, code string) (err error) {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Email: disabled",
|
||||
payload: map[string]string{"emailOtpError": "multi factor authentication with email OTP is disabled"},
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Mfa,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.Form.Set("action", "verifyEmailOtp")
|
||||
|
||||
authService = &authServiceMocked{
|
||||
validateEmailOTP: func(ctx context.Context, code string) (err error) {
|
||||
return service.AuthErrDisabledMFAWithEmailOTP()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Email: auth failed for disabled user",
|
||||
payload: map[string]string{"emailOtpError": "invalid username and password combination"},
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Mfa,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.Form.Set("action", "verifyEmailOtp")
|
||||
|
||||
authService = &authServiceMocked{
|
||||
validateEmailOTP: func(ctx context.Context, code string) (err error) {
|
||||
return service.AuthErrFailedForUnknownUser()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Email: invalid token",
|
||||
payload: map[string]string{"emailOtpError": "invalid code"},
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Mfa,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.Form.Set("action", "verifyEmailOtp")
|
||||
req.PostForm.Add("code", "token_TOO_LONG")
|
||||
|
||||
authService = &authServiceMocked{
|
||||
validateEmailOTP: func(ctx context.Context, code string) (err error) {
|
||||
return service.AuthErrInvalidEmailOTP()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Email: no token in credentials db",
|
||||
payload: map[string]string{"emailOtpError": "invalid code"},
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Mfa,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.Form.Set("action", "verifyEmailOtp")
|
||||
req.PostForm.Add("code", "123456")
|
||||
|
||||
authService = &authServiceMocked{
|
||||
validateEmailOTP: func(ctx context.Context, code string) (err error) {
|
||||
return service.AuthErrInvalidEmailOTP()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TOTP: disabled",
|
||||
payload: map[string]string{"totpError": "multi factor authentication with TOTP is disabled"},
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Mfa,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.Form.Set("action", "verifyTotp")
|
||||
|
||||
authService = &authServiceMocked{
|
||||
validateTOTP: func(ctx context.Context, code string) (err error) {
|
||||
return service.AuthErrDisabledMFAWithTOTP()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TOTP: auth failed for disabled user",
|
||||
payload: map[string]string{"totpError": "invalid username and password combination"},
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Mfa,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.Form.Set("action", "verifyTotp")
|
||||
|
||||
authService = &authServiceMocked{
|
||||
validateTOTP: func(ctx context.Context, code string) (err error) {
|
||||
return service.AuthErrFailedForUnknownUser()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TOTP: invalid token",
|
||||
payload: map[string]string{"totpError": "invalid code"},
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Mfa,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.Form.Set("action", "verifyTotp")
|
||||
req.PostForm.Add("code", "token_TOO_LONG")
|
||||
|
||||
authService = &authServiceMocked{
|
||||
validateTOTP: func(ctx context.Context, code string) (err error) {
|
||||
return service.AuthErrInvalidTOTP()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TOTP: no token in credentials db",
|
||||
payload: map[string]string{"totpError": "invalid code"},
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Mfa,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.Form.Set("action", "verifyTotp")
|
||||
req.PostForm.Add("code", "123456")
|
||||
|
||||
authService = &authServiceMocked{
|
||||
validateTOTP: func(ctx context.Context, code string) (err error) {
|
||||
return service.AuthErrInvalidTOTP()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rq := require.New(t)
|
||||
|
||||
// reset from previous
|
||||
req.Form = url.Values{}
|
||||
req.PostForm = url.Values{}
|
||||
user.Meta = &types.UserMeta{}
|
||||
|
||||
authSettings := &settings.Settings{}
|
||||
|
||||
tc.fn(authSettings)
|
||||
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
err := authHandlers.mfaProc(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(tc.payload, authReq.GetKV())
|
||||
rq.Equal(tc.alerts, authReq.NewAlerts)
|
||||
rq.Equal(tc.link, authReq.RedirectTo)
|
||||
})
|
||||
}
|
||||
}
|
||||
211
server/auth/handlers/handle_mfa_totp.go
Normal file
211
server/auth/handlers/handle_mfa_totp.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base32"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"go.uber.org/zap"
|
||||
"rsc.io/qr"
|
||||
)
|
||||
|
||||
const (
|
||||
// session key where the secret is kept between requests
|
||||
totpSecretKey = "totpSecret"
|
||||
)
|
||||
|
||||
// Handles MFA TOTP configuration form
|
||||
//
|
||||
// Where the TOTP QR & code are displayed and where
|
||||
func (h *AuthHandlers) mfaTotpConfigForm(req *request.AuthReq) (err error) {
|
||||
var (
|
||||
rawSecret [10]byte
|
||||
secret string
|
||||
|
||||
// this is more for debugging & development purposes
|
||||
// but it does not hurt to keep it here
|
||||
_, fresh = req.Request.URL.Query()["fresh"]
|
||||
)
|
||||
|
||||
if s, has := req.Session.Values[totpSecretKey]; has && !fresh {
|
||||
// secret is already in the session and
|
||||
// there's no explicit request to change it
|
||||
secret = s.(string)
|
||||
} else {
|
||||
rand.Read(rawSecret[:])
|
||||
secret = base32.StdEncoding.EncodeToString(rawSecret[:])
|
||||
req.Session.Values[totpSecretKey] = secret
|
||||
}
|
||||
|
||||
req.Data["secret"] = secret
|
||||
req.Data["enforced"] = h.Settings.MultiFactor.TOTP.Enforced
|
||||
req.Data["form"] = req.PopKV()
|
||||
req.Template = TmplMfaTotp
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handles MFA OTP form processing
|
||||
func (h *AuthHandlers) mfaTotpConfigProc(req *request.AuthReq) (err error) {
|
||||
req.RedirectTo = GetLinks().MfaTotpNewSecret
|
||||
req.SetKV(nil)
|
||||
|
||||
var (
|
||||
user *types.User
|
||||
secret, has = req.Session.Values[totpSecretKey]
|
||||
)
|
||||
|
||||
if !has {
|
||||
return fmt.Errorf("no TOTP secret in session")
|
||||
}
|
||||
|
||||
// Here is where code validation is done and where the secret is stored
|
||||
user, err = h.AuthService.ConfigureTOTP(
|
||||
auth.SetIdentityToContext(req.Context(), req.AuthUser.User),
|
||||
secret.(string),
|
||||
req.Request.PostFormValue("code"),
|
||||
)
|
||||
|
||||
t := translator(req, "auth")
|
||||
if err == nil {
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "primary",
|
||||
Text: t("mfa-totp.alerts.text-MFA-enabled"),
|
||||
})
|
||||
|
||||
// Make sure we update User's data in the session
|
||||
req.AuthUser.User = user
|
||||
req.AuthUser.CompleteTOTP()
|
||||
req.AuthUser.Save(req.Session)
|
||||
|
||||
h.Log.Info("TOTP code verified")
|
||||
req.RedirectTo = GetLinks().Security
|
||||
delete(req.Session.Values, totpSecretKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.IsInvalidData(err):
|
||||
req.SetKV(map[string]string{
|
||||
"error": t("mfa-totp.errors.invalid-code-format"),
|
||||
})
|
||||
return nil
|
||||
case errors.IsUnauthenticated(err):
|
||||
req.SetKV(map[string]string{
|
||||
"error": t("mfa-totp.errors.invalid-code"),
|
||||
})
|
||||
return nil
|
||||
|
||||
default:
|
||||
// Just in case, delete secret if something unexpected happend
|
||||
delete(req.Session.Values, totpSecretKey)
|
||||
h.Log.Error("unhandled error", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Displays the QR PNG image
|
||||
func (h *AuthHandlers) mfaTotpConfigQR(req *request.AuthReq) (err error) {
|
||||
var (
|
||||
issuer = h.Settings.MultiFactor.TOTP.Issuer
|
||||
secret, has = req.Session.Values[totpSecretKey]
|
||||
)
|
||||
|
||||
if !has {
|
||||
return fmt.Errorf("no secret in session")
|
||||
}
|
||||
|
||||
if len(issuer) == 0 {
|
||||
issuer = "Corteza"
|
||||
}
|
||||
|
||||
account := req.AuthUser.User.Email
|
||||
|
||||
if len(account) == 0 {
|
||||
account = req.AuthUser.User.Handle
|
||||
}
|
||||
|
||||
URL, err := url.Parse("otpauth://totp")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
URL.Path += "/" + url.PathEscape(issuer) + ":" + url.PathEscape(account)
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("secret", secret.(string))
|
||||
params.Add("issuer", issuer)
|
||||
|
||||
URL.RawQuery = params.Encode()
|
||||
|
||||
code, err := qr.Encode(URL.String(), qr.Q)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
req.Status = -1
|
||||
_, err = req.Response.Write(code.PNG())
|
||||
return
|
||||
}
|
||||
|
||||
// Handles MFA TOTP configuration form
|
||||
//
|
||||
// Where the TOTP QR & code are displayed and where
|
||||
func (h *AuthHandlers) mfaTotpDisableForm(req *request.AuthReq) (err error) {
|
||||
req.Data["form"] = req.PopKV()
|
||||
req.Template = TmplMfaTotpDisable
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handles MFA OTP form processing
|
||||
func (h *AuthHandlers) mfaTotpDisableProc(req *request.AuthReq) (err error) {
|
||||
req.RedirectTo = GetLinks().MfaTotpDisable
|
||||
req.SetKV(nil)
|
||||
|
||||
var user *types.User
|
||||
|
||||
// Here is where code validation is done and where the secret is stored
|
||||
user, err = h.AuthService.RemoveTOTP(
|
||||
req.Context(),
|
||||
req.AuthUser.User.ID,
|
||||
req.Request.PostFormValue("code"),
|
||||
)
|
||||
|
||||
t := translator(req, "auth")
|
||||
if err == nil {
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "primary",
|
||||
Text: t("mfa-totp.alerts.text-MFA-disabled"),
|
||||
})
|
||||
|
||||
// Make sure we update User's data in the session
|
||||
req.AuthUser.User = user
|
||||
req.AuthUser.Update(h.Settings, user)
|
||||
req.AuthUser.Save(req.Session)
|
||||
|
||||
h.Log.Info("TOTP disabled")
|
||||
req.RedirectTo = GetLinks().Security
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.IsInvalidData(err):
|
||||
req.SetKV(map[string]string{
|
||||
"error": t("mfa-totp.errors.invalid-code-format"),
|
||||
})
|
||||
return nil
|
||||
case errors.IsUnauthenticated(err):
|
||||
req.SetKV(map[string]string{
|
||||
"error": t("mfa-totp.errors.invalid-code"),
|
||||
})
|
||||
return nil
|
||||
|
||||
default:
|
||||
h.Log.Error("unhandled error", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
708
server/auth/handlers/handle_oauth2.go
Normal file
708
server/auth/handlers/handle_oauth2.go
Normal file
@@ -0,0 +1,708 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"github.com/go-chi/jwtauth"
|
||||
oauth2errors "github.com/go-oauth2/oauth2/v4/errors"
|
||||
"github.com/lestrrat-go/jwx/jwa"
|
||||
"github.com/lestrrat-go/jwx/jwk"
|
||||
"github.com/lestrrat-go/jwx/jwt"
|
||||
"github.com/spf13/cast"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/pkg/payload"
|
||||
systemService "github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
oauth2def "github.com/go-oauth2/oauth2/v4"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// oauth2 flow authorize step
|
||||
//
|
||||
// OA2 server internals first run user check (see SetUserAuthorizationHandler lambda)
|
||||
// to ensure user is authenticated;
|
||||
func (h *AuthHandlers) oauth2Authorize(req *request.AuthReq) (err error) {
|
||||
if form := request.GetOAuth2AuthParams(req.Session); form != nil {
|
||||
req.Request.Form = form
|
||||
h.Log.Debug("restarting oauth2 authorization flow", zap.Any("params", req.Request.Form))
|
||||
} else {
|
||||
h.Log.Debug("starting new oauth2 authorization flow", zap.Any("params", req.Request.Form))
|
||||
}
|
||||
|
||||
request.SetOauth2AuthParams(req.Session, nil)
|
||||
|
||||
var (
|
||||
client *types.AuthClient
|
||||
)
|
||||
|
||||
if client, err = h.loadRequestedClient(req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if client != nil {
|
||||
// No client validation is done at this point;
|
||||
// first, see if user is able to authenticate.
|
||||
request.SetOauth2Client(req.Session, client)
|
||||
|
||||
// ensure we're dealing with client ID in case someone used handle
|
||||
req.Request.Form.Set("client_id", strconv.FormatUint(client.ID, 10))
|
||||
}
|
||||
|
||||
// set to -1 to make sure wrapping request handler
|
||||
// does not send status code!
|
||||
req.Status = -1
|
||||
|
||||
err = h.OAuth2.HandleAuthorizeRequest(req.Response, req.Request)
|
||||
if err != nil {
|
||||
req.Status = http.StatusInternalServerError
|
||||
req.Template = TmplInternalError
|
||||
req.Data["error"] = err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) oauth2AuthorizeClient(req *request.AuthReq) (err error) {
|
||||
var (
|
||||
client = request.GetOauth2Client(req.Session)
|
||||
)
|
||||
|
||||
if client == nil {
|
||||
return fmt.Errorf("flow broken; client missing")
|
||||
}
|
||||
|
||||
if err = client.Verify(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !h.canAuthorizeClient(req.Context(), req.Client) {
|
||||
h.Log.Error("user's roles do not allow authorization of this client", logger.Uint64("ID", req.Client.ID), zap.String("handle", req.Client.Handle))
|
||||
request.SetOauth2Client(req.Session, nil)
|
||||
request.SetOauth2AuthParams(req.Session, nil)
|
||||
req.RedirectTo = GetLinks().Profile
|
||||
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "danger",
|
||||
Text: t("oauth2-authorize-client.alerts.denied", "client", req.Client.Meta.Name),
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
if !req.AuthUser.User.EmailConfirmed {
|
||||
req.Data["invalidUser"] = true
|
||||
req.Data["disabled"] = true
|
||||
} else if client.Trusted {
|
||||
h.Log.Debug("pre-authorized trusted oauth2 client")
|
||||
|
||||
// Client is trusted, no need to show this screen
|
||||
// move forward and authorize oauth2 request
|
||||
request.SetOauth2ClientAuthorized(req.Session, true)
|
||||
req.RedirectTo = GetLinks().OAuth2Authorize
|
||||
return nil
|
||||
}
|
||||
|
||||
h.Log.Debug("showing oauth2 client auth form")
|
||||
|
||||
req.Template = TmplOAuth2AuthorizeClient
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) oauth2AuthorizeClientProc(req *request.AuthReq) (err error) {
|
||||
// permissions are already check ed in the oauth2AuthorizeClient fn,
|
||||
// just making sure
|
||||
if h.canAuthorizeClient(req.Context(), req.Client) {
|
||||
request.SetOauth2Client(req.Session, nil)
|
||||
if _, allow := req.Request.Form["allow"]; allow {
|
||||
request.SetOauth2ClientAuthorized(req.Session, true)
|
||||
req.RedirectTo = GetLinks().OAuth2Authorize
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// handle deny client action from authorize-client form
|
||||
//
|
||||
// This occurs when user pressed "DENY" button on authorize-client form
|
||||
// Remove all and redirect to profile
|
||||
//
|
||||
request.SetOauth2AuthParams(req.Session, nil)
|
||||
req.RedirectTo = GetLinks().Profile
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "warning",
|
||||
Text: t("oauth2-authorize-client.alerts.denied", "client", req.Client.Meta.Name),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verifies weather current user can authorize this client or not
|
||||
func (h *AuthHandlers) canAuthorizeClient(ctx context.Context, c *types.AuthClient) bool {
|
||||
return systemService.DefaultAccessControl.CanAuthorizeAuthClient(ctx, c)
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) oauth2Token(req *request.AuthReq) (err error) {
|
||||
// Cleanup
|
||||
request.SetOauth2ClientAuthorized(req.Session, false)
|
||||
|
||||
req.Status = -1
|
||||
|
||||
client, err := h.loadRequestedClient(req)
|
||||
if err != nil {
|
||||
return h.tokenError(req.Response, err)
|
||||
}
|
||||
|
||||
return h.handleTokenRequest(req, client)
|
||||
}
|
||||
|
||||
// oauth2Info handler validates token and responds with decoded claims
|
||||
func (h *AuthHandlers) oauth2Info(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
jt jwt.Token
|
||||
claims map[string]interface{}
|
||||
)
|
||||
|
||||
err := func() (err error) {
|
||||
if jt, claims, err = jwtauth.FromContext(r.Context()); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = auth.TokenIssuer.Validate(r.Context(), jt); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
// Gracefully handle request cancellation
|
||||
//
|
||||
// This happens during the login procedure when browser is
|
||||
// sent through a couple of quick redirects that can
|
||||
// terminate requests
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
data = make(map[string]interface{})
|
||||
code int
|
||||
header http.Header
|
||||
)
|
||||
|
||||
if errors.Is(err, oauth2errors.ErrInvalidAccessToken) {
|
||||
code = http.StatusForbidden
|
||||
data["error"] = err.Error()
|
||||
} else {
|
||||
data, code, header = h.OAuth2.GetErrorData(err)
|
||||
}
|
||||
|
||||
_ = header.Write(w)
|
||||
w.WriteHeader(code)
|
||||
|
||||
data["active"] = false
|
||||
_ = json.NewEncoder(w).Encode(data)
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(claims)
|
||||
}
|
||||
|
||||
// oauth2authorizeDefaultClient acts as a proxy for default client
|
||||
//
|
||||
// Responsibilities:
|
||||
// - handles parameterless request to initialize authorization code flow
|
||||
// - accepts redirect_uri via query string that's used to build the oauth2 authorization URL
|
||||
//
|
||||
// (for the rest of the flow, see oauth2authorizeDefaultClientProc)
|
||||
func (h *AuthHandlers) oauth2authorizeDefaultClient(req *request.AuthReq) (err error) {
|
||||
if err = h.verifyDefaultClient(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
params = url.Values{}
|
||||
)
|
||||
|
||||
params.Set("client_id", fmt.Sprintf("%d", h.DefaultClient.ID))
|
||||
params.Set("response_type", "code")
|
||||
params.Set("response_mode", "query")
|
||||
|
||||
if redir, has := req.Request.Form["redirect_uri"]; has && len(redir) > 0 {
|
||||
params.Set("redirect_uri", redir[0])
|
||||
}
|
||||
|
||||
if scope, has := req.Request.Form["scope"]; has && len(scope) > 0 {
|
||||
params.Set("scope", scope[0])
|
||||
}
|
||||
|
||||
if state, has := req.Request.Form["state"]; has && len(state) > 0 {
|
||||
params.Set("state", state[0])
|
||||
}
|
||||
|
||||
req.RedirectTo = GetLinks().OAuth2Authorize + "?" + params.Encode()
|
||||
return
|
||||
}
|
||||
|
||||
// oauth2authorizeDefaultClient acts as a proxy for default client
|
||||
//
|
||||
// Responsibilities:
|
||||
// - handles exchange of authorization-code for token
|
||||
// - handles issuing of new access token requests
|
||||
//
|
||||
// (for the first part of the flow, see oauth2authorizeDefaultClient)
|
||||
func (h *AuthHandlers) oauth2authorizeDefaultClientProc(req *request.AuthReq) (err error) {
|
||||
if err = h.verifyDefaultClient(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
// Clone of the initial request
|
||||
// that we'll use for token request validation
|
||||
r = req.Request.Clone(req.Context())
|
||||
)
|
||||
|
||||
if _, has := r.Form["code"]; has {
|
||||
r.Form.Set("grant_type", oauth2def.AuthorizationCode.String())
|
||||
} else if _, has := r.Form["refresh_token"]; has {
|
||||
r.Form.Set("grant_type", oauth2def.Refreshing.String())
|
||||
} else {
|
||||
// make sure we do not get surprised, remove grant_type param
|
||||
// and let oauth2 internals invalidate the request
|
||||
r.Form.Del("grant_type")
|
||||
}
|
||||
|
||||
// Add id and secret from the default client
|
||||
r.SetBasicAuth(
|
||||
strconv.FormatUint(h.DefaultClient.ID, 10),
|
||||
h.DefaultClient.Secret,
|
||||
)
|
||||
|
||||
req.Request = r
|
||||
|
||||
return h.handleTokenRequest(req, h.DefaultClient)
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) verifyDefaultClient() error {
|
||||
if h.DefaultClient == nil {
|
||||
return fmt.Errorf("no default client configured")
|
||||
}
|
||||
|
||||
if err := h.DefaultClient.Verify(); err != nil {
|
||||
return fmt.Errorf("invalid client: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loads client from the request params and verifies other request params against client settings
|
||||
func (h *AuthHandlers) loadRequestedClient(req *request.AuthReq) (client *types.AuthClient, err error) {
|
||||
return client, func() (err error) {
|
||||
var (
|
||||
id string
|
||||
found bool
|
||||
)
|
||||
|
||||
if id, _, found = req.Request.BasicAuth(); !found {
|
||||
if _, found = req.Request.Form["client_id"]; !found {
|
||||
return
|
||||
} else {
|
||||
id = req.Request.Form.Get("client_id")
|
||||
}
|
||||
}
|
||||
|
||||
client, err = h.ClientService.Lookup(req.Context(), id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid client: %w", err)
|
||||
}
|
||||
|
||||
h.Log.Debug("client loaded from store", logger.Uint64("ID", client.ID))
|
||||
return
|
||||
}()
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) handleTokenRequest(req *request.AuthReq, client *types.AuthClient) error {
|
||||
var (
|
||||
r = req.Request
|
||||
w = req.Response
|
||||
ctx = req.Context()
|
||||
|
||||
user *types.User
|
||||
)
|
||||
|
||||
req.Status = -1
|
||||
|
||||
if err := client.Verify(); err != nil {
|
||||
return h.tokenError(w, fmt.Errorf("invalid client: %w", err))
|
||||
}
|
||||
|
||||
r = req.Request.Clone(ctx)
|
||||
|
||||
gt, tgr, err := h.OAuth2.ValidationTokenRequest(r)
|
||||
if err != nil {
|
||||
return h.tokenError(w, err)
|
||||
}
|
||||
|
||||
// Manually set the impersonating user & roles when client credentials
|
||||
if gt == oauth2def.ClientCredentials {
|
||||
tgr.UserID = strings.Join(
|
||||
append([]string{strconv.FormatUint(client.Security.ImpersonateUser, 10)}, client.Security.ForcedRoles...),
|
||||
" ",
|
||||
)
|
||||
}
|
||||
|
||||
ti, err := h.OAuth2.GetAccessToken(ctx, gt, tgr)
|
||||
if err != nil {
|
||||
return h.tokenError(w, err)
|
||||
}
|
||||
|
||||
suCtx := auth.SetIdentityToContext(ctx, auth.ServiceUser())
|
||||
|
||||
switch gt {
|
||||
case oauth2def.ClientCredentials:
|
||||
// Authenticated with client credentials!
|
||||
|
||||
// First, validate client's security settings
|
||||
if client.Security == nil || client.Security.ImpersonateUser == 0 {
|
||||
return h.tokenError(w, errors.Internal("auth client security configuration invalid"))
|
||||
}
|
||||
|
||||
// Load the user
|
||||
if user, err = h.UserService.FindByAny(suCtx, client.Security.ImpersonateUser); err != nil {
|
||||
return h.tokenError(w, fmt.Errorf("could not generate token for impersonated user: %v", err))
|
||||
}
|
||||
|
||||
case oauth2def.AuthorizationCode, oauth2def.Refreshing:
|
||||
userID := ti.GetUserID()
|
||||
if i := strings.Index(ti.GetUserID(), " "); i > 0 {
|
||||
// userID field from the token could contain encoded roles
|
||||
// @todo investigate if role-encoding into user-id field is still needed?
|
||||
userID = userID[:i]
|
||||
}
|
||||
|
||||
sessionUserExists := req.AuthUser != nil && req.AuthUser.User != nil
|
||||
|
||||
user, err = h.UserService.FindByAny(suCtx, userID)
|
||||
|
||||
if err != nil {
|
||||
if !errors.Is(err, systemService.UserErrNotFound()) {
|
||||
return h.tokenError(w, fmt.Errorf("could not generate token: %v", err))
|
||||
}
|
||||
|
||||
if errors.Is(err, systemService.UserErrNotFound()) && sessionUserExists {
|
||||
user = req.AuthUser.User
|
||||
}
|
||||
}
|
||||
|
||||
if sessionUserExists && req.AuthUser.User.ID == cast.ToUint64(userID) {
|
||||
req.AuthUser.User = user
|
||||
req.AuthUser.Save(req.Session)
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported oauth2 grant type: %v", gt)
|
||||
}
|
||||
|
||||
var (
|
||||
signed []byte
|
||||
scope = strings.Split(ti.GetScope(), " ")
|
||||
)
|
||||
|
||||
// Here set roles to signed
|
||||
signed, err = auth.TokenIssuer.Sign(
|
||||
auth.WithAccessToken(ti.GetAccess()),
|
||||
auth.WithIdentity(user),
|
||||
func(tr *auth.TokenRequest) error {
|
||||
// Calculate user's roles
|
||||
roles := user.Roles()
|
||||
if client.Security != nil {
|
||||
roles = auth.ApplyRoleSecurity(
|
||||
payload.ParseUint64s(client.Security.PermittedRoles),
|
||||
payload.ParseUint64s(client.Security.ProhibitedRoles),
|
||||
payload.ParseUint64s(client.Security.ForcedRoles),
|
||||
roles...,
|
||||
)
|
||||
}
|
||||
tr.Roles = roles
|
||||
return nil
|
||||
},
|
||||
auth.WithClientID(client.ID),
|
||||
auth.WithScope(scope...),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return h.tokenError(w, err)
|
||||
}
|
||||
|
||||
// modify token info with signed JWT
|
||||
// this will be sent back to the user
|
||||
ti.SetAccess(string(signed))
|
||||
|
||||
response := h.OAuth2.GetTokenData(ti)
|
||||
|
||||
// include user's avatarID
|
||||
if user.Meta.AvatarID != 0 {
|
||||
response["avatarID"] = strconv.FormatUint(user.Meta.AvatarID, 10)
|
||||
}
|
||||
|
||||
if h.Locale.HasLanguage(language.Make(user.Meta.PreferredLanguage)) {
|
||||
response["preferred_language"] = user.Meta.PreferredLanguage
|
||||
} else {
|
||||
response["preferred_language"] = locale.GetAcceptLanguageFromContext(req.Context()).String()
|
||||
}
|
||||
|
||||
if user.Labels != nil {
|
||||
response["labels"] = user.Labels
|
||||
} else {
|
||||
response["labels"] = make(map[string]interface{})
|
||||
}
|
||||
|
||||
//include user's theme
|
||||
response["theme"] = user.Meta.Theme
|
||||
|
||||
// in case client is configured with "openid" scope,
|
||||
// we'll add "id_token" with all required (by OIDC) details encoded
|
||||
if strings.Contains(client.Scope, "openid") {
|
||||
var idToken []byte
|
||||
if idToken, err = generateIdToken(user, client, ti, h.Opt.BaseURL); err != nil {
|
||||
return h.tokenError(w, err)
|
||||
}
|
||||
response["id_token"] = string(idToken)
|
||||
}
|
||||
|
||||
return writeResponse(w, response, nil)
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) tokenError(w http.ResponseWriter, err error) error {
|
||||
data, statusCode, header := h.OAuth2.GetErrorData(err)
|
||||
return writeResponse(w, data, header, statusCode)
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) oauth2PublicKeys(w http.ResponseWriter, r *http.Request) {
|
||||
// handle error
|
||||
handleErr := func(code int, err error) {
|
||||
var (
|
||||
data = make(map[string]interface{})
|
||||
header http.Header
|
||||
)
|
||||
|
||||
data["error"] = err.Error()
|
||||
|
||||
_ = header.Write(w)
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
// @todo determine weather to get it form user and save it in settings
|
||||
raw, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
handleErr(http.StatusBadRequest, fmt.Errorf("failed to generate new RSA privatre key: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
key, err := jwk.New(raw)
|
||||
if err != nil {
|
||||
handleErr(http.StatusInternalServerError, fmt.Errorf("failed to create symmetric key: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
publicKey, err := key.PublicKey()
|
||||
if err != nil {
|
||||
handleErr(http.StatusInternalServerError, fmt.Errorf("failed to create public key: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
_ = json.NewEncoder(w).Encode(publicKey)
|
||||
}
|
||||
|
||||
func SubSplit(ti oauth2def.TokenInfo, data map[string]interface{}) {
|
||||
userIdWithRoles := strings.SplitN(ti.GetUserID(), " ", 2)
|
||||
data["sub"] = userIdWithRoles[0]
|
||||
|
||||
if len(userIdWithRoles) > 1 {
|
||||
data["roles"] = strings.Split(userIdWithRoles[1], " ")
|
||||
}
|
||||
}
|
||||
|
||||
// Generates ID token that is part of OIDC flow for doing corteza-to-corteza auth
|
||||
func generateIdToken(user *types.User, client *types.AuthClient, ti oauth2def.TokenInfo, baseURL string) (_ []byte, err error) {
|
||||
token := jwt.New()
|
||||
if err = token.Set(jwt.IssuerKey, baseURL); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// we do not know what the admin used for client key value
|
||||
// on the receiving end, so we'll encode both,
|
||||
// client's ID, and it's handle
|
||||
aud := []string{strconv.FormatUint(client.ID, 10)}
|
||||
if len(client.Handle) > 0 {
|
||||
aud = append(aud, client.Handle)
|
||||
}
|
||||
|
||||
if err = token.Set("aud", aud); err != nil {
|
||||
return
|
||||
}
|
||||
if err = token.Set("user_id", strconv.FormatUint(user.ID, 10)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// sub is required by some clients (goth) so we'll provide it just in case
|
||||
if err = token.Set("sub", strconv.FormatUint(user.ID, 10)); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = token.Set("email", user.Email); err != nil {
|
||||
return
|
||||
}
|
||||
if err = token.Set("email_verified", user.EmailConfirmed); err != nil {
|
||||
return
|
||||
}
|
||||
if err = token.Set(jwt.ExpirationKey, now().Add(ti.GetAccessExpiresIn()).Unix()); err != nil {
|
||||
return
|
||||
}
|
||||
if err = token.Set(jwt.IssuedAtKey, now().Unix()); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// profile claims are only added when the token is scoped for them
|
||||
if auth.CheckScope(ti.GetScope(), "profile") {
|
||||
for claim, value := range profileClaims(user) {
|
||||
if err = token.Set(claim, value); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return jwt.Sign(token, jwa.HS512, []byte(client.Secret))
|
||||
}
|
||||
|
||||
// profileClaims returns OIDC profile-scope claims for the given user
|
||||
func profileClaims(user *types.User) map[string]interface{} {
|
||||
cc := make(map[string]interface{})
|
||||
|
||||
if user.Name != "" {
|
||||
cc["name"] = user.Name
|
||||
}
|
||||
|
||||
if user.Handle != "" {
|
||||
cc["preferred_username"] = user.Handle
|
||||
}
|
||||
|
||||
if user.Meta != nil && user.Meta.PreferredLanguage != "" {
|
||||
cc["locale"] = user.Meta.PreferredLanguage
|
||||
}
|
||||
|
||||
if user.UpdatedAt != nil {
|
||||
cc["updated_at"] = user.UpdatedAt.Unix()
|
||||
} else if !user.CreatedAt.IsZero() {
|
||||
cc["updated_at"] = user.CreatedAt.Unix()
|
||||
}
|
||||
|
||||
return cc
|
||||
}
|
||||
|
||||
// oauth2Userinfo returns claims about the user the access token was issued for
|
||||
//
|
||||
// Claims are filtered by the scope of the token; sub is always included
|
||||
func (h *AuthHandlers) oauth2Userinfo(w http.ResponseWriter, r *http.Request) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
|
||||
jt jwt.Token
|
||||
err error
|
||||
)
|
||||
|
||||
if jt, _, err = jwtauth.FromContext(ctx); err != nil || jt == nil {
|
||||
h.userinfoError(w, fmt.Errorf("unauthorized: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err = auth.TokenIssuer.Validate(ctx, jt); err != nil {
|
||||
h.userinfoError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
sub, _ := jt.Get("sub")
|
||||
userID, _ := auth.ExtractFromSubClaim(cast.ToString(sub))
|
||||
if userID == 0 {
|
||||
h.userinfoError(w, fmt.Errorf("invalid sub claim"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.UserService.FindByAny(auth.SetIdentityToContext(ctx, auth.ServiceUser()), userID)
|
||||
if err != nil {
|
||||
h.userinfoError(w, fmt.Errorf("could not load user: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
scopeClaim, _ := jt.Get("scope")
|
||||
|
||||
var (
|
||||
scope = cast.ToString(scopeClaim)
|
||||
response = map[string]interface{}{
|
||||
// sub is the only claim that is always returned
|
||||
"sub": strconv.FormatUint(user.ID, 10),
|
||||
}
|
||||
)
|
||||
|
||||
if auth.CheckScope(scope, "profile") {
|
||||
for claim, value := range profileClaims(user) {
|
||||
response[claim] = value
|
||||
}
|
||||
}
|
||||
|
||||
if auth.CheckScope(scope, "email") && user.Email != "" {
|
||||
response["email"] = user.Email
|
||||
response["email_verified"] = user.EmailConfirmed
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) userinfoError(w http.ResponseWriter, err error) {
|
||||
h.Log.Warn("userinfo request rejected", zap.Error(err))
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": "invalid_token",
|
||||
"error_description": err.Error(),
|
||||
})
|
||||
}
|
||||
|
||||
func writeResponse(w http.ResponseWriter, data map[string]interface{}, header http.Header, statusCode ...int) error {
|
||||
w.Header().Set("Content-Type", "application/json;charset=UTF-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
|
||||
for key := range header {
|
||||
w.Header().Set(key, header.Get(key))
|
||||
}
|
||||
|
||||
status := http.StatusOK
|
||||
if len(statusCode) > 0 && statusCode[0] > 0 {
|
||||
status = statusCode[0]
|
||||
}
|
||||
|
||||
w.WriteHeader(status)
|
||||
return json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
389
server/auth/handlers/handle_oauth2_test.go
Normal file
389
server/auth/handlers/handle_oauth2_test.go
Normal file
@@ -0,0 +1,389 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/auth/settings"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/go-chi/jwtauth"
|
||||
oauth2models "github.com/go-oauth2/oauth2/v4/models"
|
||||
"github.com/lestrrat-go/jwx/jwt"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func Test_oauth2AuthorizeSuccess(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{
|
||||
Form: url.Values{},
|
||||
PostForm: url.Values{},
|
||||
}
|
||||
|
||||
oauthService oauth2Service
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
)
|
||||
|
||||
tcc := []testingExpect{
|
||||
{
|
||||
name: "authorization success",
|
||||
payload: -1,
|
||||
err: "",
|
||||
template: "",
|
||||
fn: func(_ *settings.Settings) {
|
||||
oauthService = &oauth2ServiceMocked{
|
||||
handleAuthorizeRequest: func(w http.ResponseWriter, r *http.Request) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "authorization failure",
|
||||
payload: http.StatusInternalServerError,
|
||||
err: "not authorized",
|
||||
template: TmplInternalError,
|
||||
fn: func(_ *settings.Settings) {
|
||||
oauthService = &oauth2ServiceMocked{
|
||||
handleAuthorizeRequest: func(w http.ResponseWriter, r *http.Request) error {
|
||||
return fmt.Errorf("not authorized")
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rq := require.New(t)
|
||||
|
||||
authSettings := &settings.Settings{}
|
||||
|
||||
tc.fn(authSettings)
|
||||
|
||||
authHandlers = &AuthHandlers{
|
||||
Log: zap.NewNop(),
|
||||
AuthService: authService,
|
||||
Settings: authSettings,
|
||||
OAuth2: oauthService,
|
||||
}
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
err := authHandlers.oauth2Authorize(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(tc.template, authReq.Template)
|
||||
rq.Equal(tc.payload, authReq.Status)
|
||||
|
||||
if tc.err != "" {
|
||||
rq.EqualError(errors.New(tc.err), authReq.Data["error"].(error).Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_oauth2AuthorizeSuccessSetParams(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{
|
||||
Form: url.Values{},
|
||||
PostForm: url.Values{},
|
||||
}
|
||||
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
|
||||
authSettings = &settings.Settings{}
|
||||
|
||||
rq = require.New(t)
|
||||
)
|
||||
|
||||
oauthService := &oauth2ServiceMocked{
|
||||
handleAuthorizeRequest: func(w http.ResponseWriter, r *http.Request) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
authHandlers = &AuthHandlers{
|
||||
Log: zap.NewNop(),
|
||||
AuthService: authService,
|
||||
Settings: authSettings,
|
||||
OAuth2: oauthService,
|
||||
}
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
authReq.Session.Values["oauth2AuthParams"] = url.Values{"foo": []string{"bar"}}
|
||||
|
||||
err := authHandlers.oauth2Authorize(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal("", authReq.Template)
|
||||
rq.Equal(-1, authReq.Status)
|
||||
rq.Equal(nil, authReq.Data["error"])
|
||||
}
|
||||
|
||||
func Test_generateIdToken(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
)
|
||||
|
||||
signed, err := generateIdToken(
|
||||
&types.User{},
|
||||
&types.AuthClient{
|
||||
Secret: "correct horse battery stable",
|
||||
},
|
||||
&oauth2models.Token{},
|
||||
"http://cortezaproject.org",
|
||||
)
|
||||
|
||||
req.NoError(err)
|
||||
req.NotEmpty(signed)
|
||||
}
|
||||
|
||||
func Test_generateIdTokenClaims(t *testing.T) {
|
||||
var (
|
||||
updatedAt = time.Now()
|
||||
|
||||
user = &types.User{
|
||||
ID: 42,
|
||||
Name: "John Doe",
|
||||
Handle: "jdoe",
|
||||
Email: "john@example.tld",
|
||||
EmailConfirmed: true,
|
||||
UpdatedAt: &updatedAt,
|
||||
Meta: &types.UserMeta{PreferredLanguage: "sl"},
|
||||
}
|
||||
|
||||
client = &types.AuthClient{ID: 1, Secret: "correct horse battery stable"}
|
||||
)
|
||||
|
||||
tcc := []struct {
|
||||
name string
|
||||
scope string
|
||||
present []string
|
||||
absent []string
|
||||
}{
|
||||
{
|
||||
name: "without profile scope",
|
||||
scope: "openid",
|
||||
present: []string{"sub", "user_id", "email", "email_verified", "iat", "aud"},
|
||||
absent: []string{"name", "preferred_username", "locale", "updated_at"},
|
||||
},
|
||||
{
|
||||
name: "with profile scope",
|
||||
scope: "openid profile",
|
||||
present: []string{"sub", "email", "name", "preferred_username", "locale", "updated_at"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var (
|
||||
req = require.New(t)
|
||||
ti = &oauth2models.Token{}
|
||||
)
|
||||
|
||||
ti.SetScope(tc.scope)
|
||||
|
||||
signed, err := generateIdToken(user, client, ti, "https://example.tld/auth")
|
||||
req.NoError(err)
|
||||
|
||||
token, err := jwt.Parse(signed)
|
||||
req.NoError(err)
|
||||
|
||||
for _, claim := range tc.present {
|
||||
_, has := token.Get(claim)
|
||||
req.True(has, "expecting claim %q", claim)
|
||||
}
|
||||
|
||||
for _, claim := range tc.absent {
|
||||
_, has := token.Get(claim)
|
||||
req.False(has, "not expecting claim %q", claim)
|
||||
}
|
||||
|
||||
req.Equal("https://example.tld/auth", token.Issuer())
|
||||
req.Equal("42", token.Subject())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_oauth2Userinfo(t *testing.T) {
|
||||
var (
|
||||
updatedAt = time.Now()
|
||||
|
||||
user = &types.User{
|
||||
ID: 42,
|
||||
Name: "John Doe",
|
||||
Handle: "jdoe",
|
||||
Email: "john@example.tld",
|
||||
EmailConfirmed: true,
|
||||
UpdatedAt: &updatedAt,
|
||||
Meta: &types.UserMeta{PreferredLanguage: "sl"},
|
||||
}
|
||||
)
|
||||
|
||||
// swap the global token issuer for one we can sign test tokens with
|
||||
original := auth.TokenIssuer
|
||||
t.Cleanup(func() { auth.TokenIssuer = original })
|
||||
|
||||
issuer, err := auth.NewTokenIssuer(
|
||||
auth.WithSecretSigner("correct horse battery stable"),
|
||||
auth.WithLookup(func(ctx context.Context, tokenID string) error { return nil }),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
auth.TokenIssuer = issuer
|
||||
|
||||
tcc := []struct {
|
||||
name string
|
||||
scope string
|
||||
noToken bool
|
||||
notFound bool
|
||||
status int
|
||||
claims map[string]interface{}
|
||||
absent []string
|
||||
}{
|
||||
{
|
||||
name: "openid only",
|
||||
scope: "openid",
|
||||
status: http.StatusOK,
|
||||
claims: map[string]interface{}{"sub": "42"},
|
||||
absent: []string{"name", "preferred_username", "locale", "email", "email_verified"},
|
||||
},
|
||||
{
|
||||
name: "profile scope",
|
||||
scope: "openid profile",
|
||||
status: http.StatusOK,
|
||||
claims: map[string]interface{}{
|
||||
"sub": "42",
|
||||
"name": "John Doe",
|
||||
"preferred_username": "jdoe",
|
||||
"locale": "sl",
|
||||
},
|
||||
absent: []string{"email", "email_verified"},
|
||||
},
|
||||
{
|
||||
name: "email scope",
|
||||
scope: "openid email",
|
||||
status: http.StatusOK,
|
||||
claims: map[string]interface{}{
|
||||
"sub": "42",
|
||||
"email": "john@example.tld",
|
||||
"email_verified": true,
|
||||
},
|
||||
absent: []string{"name", "preferred_username", "locale"},
|
||||
},
|
||||
{
|
||||
name: "no token",
|
||||
noToken: true,
|
||||
status: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "unknown user",
|
||||
scope: "openid",
|
||||
notFound: true,
|
||||
status: http.StatusUnauthorized,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var (
|
||||
rq = require.New(t)
|
||||
r = httptest.NewRequest(http.MethodGet, "/oauth2/userinfo", nil)
|
||||
rec = httptest.NewRecorder()
|
||||
)
|
||||
|
||||
if !tc.noToken {
|
||||
signed, err := auth.TokenIssuer.Sign(
|
||||
auth.WithIdentity(auth.Authenticated(user.ID)),
|
||||
auth.WithScope(strings.Split(tc.scope, " ")...),
|
||||
)
|
||||
rq.NoError(err)
|
||||
|
||||
token, err := jwt.Parse(signed)
|
||||
rq.NoError(err)
|
||||
|
||||
r = r.WithContext(jwtauth.NewContext(r.Context(), token, nil))
|
||||
}
|
||||
|
||||
h := &AuthHandlers{
|
||||
Log: zap.NewNop(),
|
||||
UserService: userServiceMocked{
|
||||
findByAny: func(context.Context, interface{}) (*types.User, error) {
|
||||
if tc.notFound {
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
return user, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
h.oauth2Userinfo(rec, r)
|
||||
rq.Equal(tc.status, rec.Code)
|
||||
|
||||
if tc.status != http.StatusOK {
|
||||
rq.Contains(rec.Header().Get("WWW-Authenticate"), "Bearer")
|
||||
return
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
rq.NoError(json.Unmarshal(rec.Body.Bytes(), &response))
|
||||
|
||||
for claim, value := range tc.claims {
|
||||
rq.Equal(value, response[claim], "claim %q", claim)
|
||||
}
|
||||
|
||||
for _, claim := range tc.absent {
|
||||
rq.NotContains(response, claim)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_SubSplitRoles(t *testing.T) {
|
||||
type (
|
||||
exp struct {
|
||||
id string
|
||||
i string
|
||||
ii []string
|
||||
}
|
||||
)
|
||||
var (
|
||||
req = require.New(t)
|
||||
ti = &oauth2models.Token{}
|
||||
d = make(map[string]interface{})
|
||||
|
||||
tii = []exp{
|
||||
{id: "1", i: "1", ii: []string{}},
|
||||
{id: "1 2", i: "1", ii: []string{"2"}},
|
||||
{id: "1 2 33 444", i: "1", ii: []string{"2", "33", "444"}},
|
||||
}
|
||||
)
|
||||
|
||||
for _, v := range tii {
|
||||
ti.SetUserID(v.id)
|
||||
SubSplit(ti, d)
|
||||
|
||||
req.Equal(v.i, d["sub"])
|
||||
|
||||
if _, is := d["roles"]; is {
|
||||
req.Equal(v.ii, d["roles"])
|
||||
}
|
||||
}
|
||||
}
|
||||
134
server/auth/handlers/handle_password-reset.go
Normal file
134
server/auth/handlers/handle_password-reset.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (h *AuthHandlers) requestPasswordResetForm(req *request.AuthReq) error {
|
||||
h.Log.Debug("showing request password reset form")
|
||||
req.Template = TmplRequestPasswordReset
|
||||
req.Data["form"] = req.PopKV()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) requestPasswordResetProc(req *request.AuthReq) (err error) {
|
||||
h.Log.Debug("processing password change request")
|
||||
|
||||
email := req.Request.PostFormValue("email")
|
||||
err = h.AuthService.SendPasswordResetToken(req.Context(), email)
|
||||
|
||||
if err == nil || errors.IsNotFound(err) {
|
||||
req.RedirectTo = GetLinks().PasswordResetRequested
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case service.AuthErrPasswordResetDisabledByConfig().Is(err):
|
||||
h.passwordResetDisabledAlert(req)
|
||||
return nil
|
||||
|
||||
default:
|
||||
h.Log.Error("unhandled error", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) passwordResetRequested(req *request.AuthReq) error {
|
||||
req.Template = TmplPasswordResetRequested
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) resetPasswordForm(req *request.AuthReq) (err error) {
|
||||
h.Log.Debug("password reset form")
|
||||
|
||||
req.Template = TmplResetPassword
|
||||
|
||||
// user not set, expecting valid token in URL
|
||||
if token := req.Request.URL.Query().Get("token"); len(token) > 0 {
|
||||
var user *types.User
|
||||
|
||||
user, err = h.AuthService.ValidatePasswordResetToken(req.Context(), token)
|
||||
if err == nil {
|
||||
// login user
|
||||
req.AuthUser = request.NewAuthUser(h.Settings, user, false)
|
||||
|
||||
if req.AuthUser.PendingEmailOTP() {
|
||||
// Email OTP enabled & pending
|
||||
//
|
||||
// If we're here it means user clicked on a link in an email;
|
||||
// we are effectively confirming email OTP
|
||||
req.AuthUser.CompleteEmailOTP()
|
||||
}
|
||||
|
||||
// redirect back to self (but without token and with user in session
|
||||
h.Log.Debug("valid password reset token found, refreshing page with stored user")
|
||||
req.RedirectTo = GetLinks().ResetPassword
|
||||
req.AuthUser.Save(req.Session)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if req.AuthUser == nil || err != nil {
|
||||
h.Log.Warn("invalid password reset token used", zap.Error(err))
|
||||
req.RedirectTo = GetLinks().RequestPasswordReset
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "warning",
|
||||
Text: t("password-reset-requested.alert.invalid-expired-password-token"),
|
||||
})
|
||||
}
|
||||
|
||||
req.Data["form"] = req.PopKV()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) resetPasswordProc(req *request.AuthReq) (err error) {
|
||||
h.Log.Debug("password reset proc")
|
||||
|
||||
err = h.AuthService.SetPassword(req.Context(), req.AuthUser.User.ID, req.Request.PostFormValue("password"))
|
||||
|
||||
if err == nil {
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "primary",
|
||||
Text: t("password-reset-requested.alert.password-reset-success"),
|
||||
})
|
||||
|
||||
req.RedirectTo = GetLinks().Profile
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case service.AuthErrPasswordResetDisabledByConfig().Is(err):
|
||||
h.passwordResetDisabledAlert(req)
|
||||
return nil
|
||||
|
||||
default:
|
||||
h.Log.Error("unhandled error", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) onlyIfPasswordResetEnabled(fn handlerFn) handlerFn {
|
||||
return func(req *request.AuthReq) error {
|
||||
if !h.Settings.PasswordResetEnabled || !h.Settings.LocalEnabled {
|
||||
h.passwordResetDisabledAlert(req)
|
||||
return nil
|
||||
}
|
||||
|
||||
return fn(req)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) passwordResetDisabledAlert(req *request.AuthReq) {
|
||||
req.RedirectTo = GetLinks().Login
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "danger",
|
||||
Text: t("password-reset-requested.alert.password-reset-disabled"),
|
||||
})
|
||||
}
|
||||
250
server/auth/handlers/handle_password-reset_test.go
Normal file
250
server/auth/handlers/handle_password-reset_test.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/auth/settings"
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_requestPasswordResetForm(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{
|
||||
URL: &url.URL{},
|
||||
}
|
||||
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
|
||||
authSettings = &settings.Settings{}
|
||||
|
||||
rq = require.New(t)
|
||||
)
|
||||
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
payload := map[string]string{"foo": "bar"}
|
||||
authReq.SetKV(payload)
|
||||
|
||||
err := authHandlers.requestPasswordResetForm(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(TmplRequestPasswordReset, authReq.Template)
|
||||
rq.Equal(authReq.Data["form"], payload)
|
||||
}
|
||||
|
||||
func Test_resetPasswordForm(t *testing.T) {
|
||||
var (
|
||||
req = &http.Request{
|
||||
URL: &url.URL{},
|
||||
}
|
||||
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
)
|
||||
|
||||
tcc := []testingExpect{
|
||||
{
|
||||
name: "request reset success",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().ResetPassword,
|
||||
template: TmplResetPassword,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.URL = &url.URL{RawQuery: "token=NOT_EMPTY"}
|
||||
|
||||
authService = &authServiceMocked{
|
||||
validatePasswordResetToken: func(ctx context.Context, token string) (user *types.User, err error) {
|
||||
u := makeMockUser()
|
||||
u.SetRoles()
|
||||
|
||||
return u, nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid password reset token",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert{{Type: "warning", Text: "password-reset-requested.alert.invalid-expired-password-token"}},
|
||||
link: GetLinks().RequestPasswordReset,
|
||||
template: TmplResetPassword,
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.URL = &url.URL{RawQuery: "token=NOT_EMPTY"}
|
||||
|
||||
authService = &authServiceMocked{
|
||||
validatePasswordResetToken: func(ctx context.Context, token string) (user *types.User, err error) {
|
||||
return nil, fmt.Errorf("invalid token")
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rq := require.New(t)
|
||||
|
||||
// reset from previous
|
||||
req.Form = url.Values{}
|
||||
req.PostForm = url.Values{}
|
||||
|
||||
authSettings := &settings.Settings{}
|
||||
|
||||
tc.fn(authSettings)
|
||||
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, nil)
|
||||
|
||||
// unset so we get to the main functionality
|
||||
authReq.AuthUser = nil
|
||||
|
||||
err := authHandlers.resetPasswordForm(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(tc.template, authReq.Template)
|
||||
rq.Equal(tc.payload, authReq.GetKV())
|
||||
rq.Equal(tc.alerts, authReq.NewAlerts)
|
||||
rq.Equal(tc.link, authReq.RedirectTo)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_requestPasswordReset(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{}
|
||||
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
)
|
||||
|
||||
tcc := []testingExpect{
|
||||
{
|
||||
name: "request reset success",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().PasswordResetRequested,
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
sendPasswordResetToken: func(ctx context.Context, email string) (err error) {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "request reset disabled",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert{{Type: "danger", Text: "password-reset-requested.alert.password-reset-disabled"}},
|
||||
link: GetLinks().Login,
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
sendPasswordResetToken: func(ctx context.Context, email string) (err error) {
|
||||
return service.AuthErrPasswordResetDisabledByConfig()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rq := require.New(t)
|
||||
|
||||
// reset from previous
|
||||
req.Form = url.Values{}
|
||||
req.PostForm = url.Values{}
|
||||
user.Meta = &types.UserMeta{}
|
||||
|
||||
authSettings := &settings.Settings{}
|
||||
|
||||
tc.fn(authSettings)
|
||||
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
err := authHandlers.requestPasswordResetProc(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(tc.payload, authReq.GetKV())
|
||||
rq.Equal(tc.alerts, authReq.NewAlerts)
|
||||
rq.Equal(tc.link, authReq.RedirectTo)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_requestPasswordProc(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{}
|
||||
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
)
|
||||
|
||||
tcc := []testingExpect{
|
||||
{
|
||||
name: "reset password success",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert{{Type: "primary", Text: "password-reset-requested.alert.password-reset-success", Html: ""}},
|
||||
link: GetLinks().Profile,
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
setPassword: func(ctx context.Context, userID uint64, password string) (err error) {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reset password disabled",
|
||||
payload: map[string]string(nil),
|
||||
alerts: []request.Alert{{Type: "danger", Text: "password-reset-requested.alert.password-reset-disabled", Html: ""}},
|
||||
link: GetLinks().Login,
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
setPassword: func(ctx context.Context, userID uint64, password string) (err error) {
|
||||
return service.AuthErrPasswordResetDisabledByConfig()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rq := require.New(t)
|
||||
|
||||
authSettings := &settings.Settings{}
|
||||
|
||||
tc.fn(authSettings)
|
||||
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
err := authHandlers.resetPasswordProc(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(tc.payload, authReq.GetKV())
|
||||
rq.Equal(tc.alerts, authReq.NewAlerts)
|
||||
rq.Equal(tc.link, authReq.RedirectTo)
|
||||
})
|
||||
}
|
||||
}
|
||||
180
server/auth/handlers/handle_profile.go
Normal file
180
server/auth/handlers/handle_profile.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"go.uber.org/zap"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (h *AuthHandlers) profileForm(req *request.AuthReq) (err error) {
|
||||
req.Template = TmplProfile
|
||||
|
||||
var (
|
||||
preferredLanguage string
|
||||
avatarUrl string
|
||||
)
|
||||
|
||||
u, err := h.UserService.FindByAny(req.Context(), req.AuthUser.User.ID)
|
||||
|
||||
if err != nil {
|
||||
h.Log.Error("find user error", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if langList := h.Locale.LocalizedList(req.Context()); len(langList) > 0 {
|
||||
req.Data["languages"] = langList
|
||||
|
||||
preferredLanguage = langList[0].Tag.String()
|
||||
if u.Meta != nil && u.Meta.PreferredLanguage != "" {
|
||||
preferredLanguage = u.Meta.PreferredLanguage
|
||||
}
|
||||
}
|
||||
|
||||
avatarUrl = fmt.Sprintf("%s/system/attachment/avatar/%d/original/%s", getApiFullPath(), u.Meta.AvatarID, types.AttachmentKindAvatar)
|
||||
|
||||
if form := req.PopKV(); len(form) > 0 {
|
||||
req.Data["form"] = form
|
||||
} else {
|
||||
req.Data["form"] = map[string]string{
|
||||
"email": u.Email,
|
||||
"handle": u.Handle,
|
||||
"name": u.Name,
|
||||
"preferredLanguage": preferredLanguage,
|
||||
"avatarUrl": avatarUrl,
|
||||
"initialTextColor": u.Meta.AvatarColor,
|
||||
"initialBgColor": u.Meta.AvatarBgColor,
|
||||
}
|
||||
}
|
||||
|
||||
req.Data["emailConfirmationRequired"] = !u.EmailConfirmed && h.Settings.EmailConfirmationRequired
|
||||
req.Data["avatarEnabled"] = h.Settings.ProfileAvatarEnabled
|
||||
|
||||
if h.Settings.ProfileAvatarEnabled {
|
||||
req.Data["isAvatar"] = false
|
||||
|
||||
if u.Meta.AvatarKind == types.AttachmentKindAvatar {
|
||||
req.Data["isAvatar"] = true
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) profileProc(req *request.AuthReq) error {
|
||||
req.RedirectTo = GetLinks().Profile
|
||||
req.SetKV(nil)
|
||||
|
||||
var (
|
||||
t = translator(req, "auth")
|
||||
)
|
||||
|
||||
u, err := h.UserService.FindByAny(req.Context(), req.AuthUser.User.ID)
|
||||
if err != nil {
|
||||
h.Log.Error("find user error", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
u.Handle = req.Request.PostFormValue("handle")
|
||||
u.Name = req.Request.PostFormValue("name")
|
||||
|
||||
if pl := req.Request.PostFormValue("preferredLanguage"); pl != "" {
|
||||
if u.Meta == nil {
|
||||
u.Meta = &types.UserMeta{}
|
||||
}
|
||||
|
||||
u.Meta.PreferredLanguage = pl
|
||||
}
|
||||
|
||||
// a little workaround to inject current user as authenticated identity into context
|
||||
// this way user service will pass us through.
|
||||
user, err := h.UserService.Update(req.Context(), u)
|
||||
|
||||
// process avatar initial generation
|
||||
bgColor := req.Request.PostFormValue("initial-bg")
|
||||
initialColor := req.Request.PostFormValue("initial-color")
|
||||
if bgColor != u.Meta.AvatarBgColor || initialColor != u.Meta.AvatarColor {
|
||||
err = h.UserService.GenerateAvatar(
|
||||
req.Context(),
|
||||
u.ID,
|
||||
bgColor,
|
||||
initialColor,
|
||||
)
|
||||
}
|
||||
|
||||
// process avatar upload
|
||||
_, header, _ := req.Request.FormFile("avatar")
|
||||
if header != nil {
|
||||
err = h.UserService.UploadAvatar(
|
||||
req.Context(),
|
||||
u.ID,
|
||||
header,
|
||||
)
|
||||
}
|
||||
|
||||
// Process avatar delete
|
||||
if req.Request.PostFormValue("avatar-delete") == "avatar-delete" {
|
||||
if err = h.UserService.DeleteAvatar(req.Context(), u.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
err = h.AuthService.LoadRoleMemberships(req.Context(), user)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
req.AuthUser.User = user
|
||||
req.AuthUser.Save(req.Session)
|
||||
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "primary",
|
||||
Text: t("profile.alerts.profile-updated"),
|
||||
})
|
||||
|
||||
req.RedirectTo = GetLinks().Profile
|
||||
return nil
|
||||
}
|
||||
|
||||
avatarUrl := fmt.Sprintf("%s/system/attachment/avatar/%d/original/%s", getApiFullPath(), u.Meta.AvatarID, types.AttachmentKindAvatar)
|
||||
switch {
|
||||
case
|
||||
service.UserErrInvalidID().Is(err),
|
||||
service.UserErrInvalidHandle().Is(err),
|
||||
service.UserErrInvalidEmail().Is(err),
|
||||
service.UserErrHandleNotUnique().Is(err),
|
||||
service.UserErrNotAllowedToUpdate().Is(err),
|
||||
service.AttachmentErrInvalidAvatarFileType().Is(err),
|
||||
service.AttachmentErrInvalidAvatarFileSize().Is(err),
|
||||
service.AttachmentErrInvalidAvatarGenerateFontFile().Is(err):
|
||||
req.SetKV(map[string]string{
|
||||
"error": err.Error(),
|
||||
"email": u.Email,
|
||||
"handle": u.Handle,
|
||||
"name": u.Name,
|
||||
"avatarUrl": avatarUrl,
|
||||
"initialTextColor": u.Meta.AvatarColor,
|
||||
"initialBgColor": u.Meta.AvatarBgColor,
|
||||
})
|
||||
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "danger",
|
||||
Text: t("profile.alerts.profile-update-fail"),
|
||||
})
|
||||
|
||||
h.Log.Warn("handled error", zap.Error(err))
|
||||
return nil
|
||||
|
||||
default:
|
||||
h.Log.Error("unhandled error", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func getApiFullPath() (path string) {
|
||||
return fmt.Sprintf("/%s", strings.TrimPrefix(options.CleanBase(options.HttpServer().BaseUrl, options.HttpServer().ApiBaseUrl), "/"))
|
||||
}
|
||||
296
server/auth/handlers/handle_profile_test.go
Normal file
296
server/auth/handlers/handle_profile_test.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/auth/settings"
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_profileForm(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{
|
||||
URL: &url.URL{},
|
||||
}
|
||||
|
||||
authService authService
|
||||
userService userService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
|
||||
authSettings = &settings.Settings{
|
||||
EmailConfirmationRequired: false,
|
||||
}
|
||||
|
||||
rq = require.New(t)
|
||||
)
|
||||
|
||||
userService = &userServiceMocked{
|
||||
findByAny: func(ctx context.Context, i interface{}) (*types.User, error) {
|
||||
u := makeMockUser()
|
||||
u.ID = i.(uint64)
|
||||
|
||||
return u, nil
|
||||
},
|
||||
}
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authHandlers.UserService = userService
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
avatarUrl := fmt.Sprintf("//system/attachment/avatar/%d/original/%s", user.Meta.AvatarID, types.AttachmentKindAvatar)
|
||||
|
||||
userForm := map[string]string{
|
||||
"email": user.Email,
|
||||
"handle": user.Handle,
|
||||
"name": user.Name,
|
||||
"avatarUrl": avatarUrl,
|
||||
"preferredLanguage": user.Meta.PreferredLanguage,
|
||||
"initialTextColor": user.Meta.AvatarColor,
|
||||
"initialBgColor": user.Meta.AvatarBgColor,
|
||||
}
|
||||
|
||||
authReq.SetKV(map[string]string{})
|
||||
|
||||
err := authHandlers.profileForm(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(TmplProfile, authReq.Template)
|
||||
rq.Equal(authReq.Data["form"], userForm)
|
||||
rq.Equal(authReq.Data["emailConfirmationRequired"], false)
|
||||
}
|
||||
|
||||
func Test_profileFormProc(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{
|
||||
PostForm: url.Values{},
|
||||
}
|
||||
|
||||
authService authService
|
||||
userService userService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
)
|
||||
|
||||
tcc := []testingExpect{
|
||||
{
|
||||
name: "success",
|
||||
err: "request Content-Type isn't multipart/form-data",
|
||||
alerts: []request.Alert{{Type: "primary", Text: "profile.alerts.profile-updated", Html: ""}},
|
||||
link: GetLinks().Profile,
|
||||
payload: map[string]string(nil),
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.PostForm.Add("handle", "handle")
|
||||
req.PostForm.Add("name", "name")
|
||||
|
||||
authService = &authServiceMocked{}
|
||||
|
||||
userService = &userServiceMocked{
|
||||
update: func(c context.Context, u *types.User) (*types.User, error) {
|
||||
u = makeMockUser()
|
||||
u.SetRoles()
|
||||
|
||||
return u, nil
|
||||
},
|
||||
findByAny: func(ctx context.Context, i interface{}) (*types.User, error) {
|
||||
u := makeMockUser()
|
||||
u.ID = i.(uint64)
|
||||
|
||||
return u, nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "proc invalid ID",
|
||||
err: "",
|
||||
alerts: []request.Alert{{Type: "danger", Text: "profile.alerts.profile-update-fail", Html: ""}},
|
||||
link: GetLinks().Profile,
|
||||
payload: map[string]string{
|
||||
"email": "mockuser@example.tld",
|
||||
"error": "invalid ID",
|
||||
"handle": "handle",
|
||||
"name": "name",
|
||||
"initialBgColor": "",
|
||||
"initialTextColor": "",
|
||||
"avatarUrl": "//system/attachment/avatar/0/original/avatar",
|
||||
},
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.PostForm.Add("handle", "handle")
|
||||
req.PostForm.Add("name", "name")
|
||||
|
||||
userService = &userServiceMocked{
|
||||
update: func(c context.Context, u *types.User) (*types.User, error) {
|
||||
return nil, service.UserErrInvalidID()
|
||||
},
|
||||
findByAny: func(ctx context.Context, i interface{}) (*types.User, error) {
|
||||
u := makeMockUser()
|
||||
u.ID = i.(uint64)
|
||||
|
||||
return u, nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "proc invalid handle",
|
||||
err: "",
|
||||
alerts: []request.Alert{{Type: "danger", Text: "profile.alerts.profile-update-fail", Html: ""}},
|
||||
link: GetLinks().Profile,
|
||||
payload: map[string]string{
|
||||
"email": "mockuser@example.tld",
|
||||
"error": "invalid handle",
|
||||
"handle": "handle",
|
||||
"name": "name",
|
||||
"initialBgColor": "",
|
||||
"initialTextColor": "",
|
||||
"avatarUrl": "//system/attachment/avatar/0/original/avatar",
|
||||
},
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.PostForm.Add("handle", "handle")
|
||||
req.PostForm.Add("name", "name")
|
||||
|
||||
userService = &userServiceMocked{
|
||||
update: func(c context.Context, u *types.User) (*types.User, error) {
|
||||
return nil, service.UserErrInvalidHandle()
|
||||
},
|
||||
findByAny: func(ctx context.Context, i interface{}) (*types.User, error) {
|
||||
u := makeMockUser()
|
||||
u.ID = i.(uint64)
|
||||
|
||||
return u, nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "proc invalid email",
|
||||
err: "",
|
||||
alerts: []request.Alert{{Type: "danger", Text: "profile.alerts.profile-update-fail", Html: ""}},
|
||||
link: GetLinks().Profile,
|
||||
payload: map[string]string{
|
||||
"email": "mockuser@example.tld",
|
||||
"error": "invalid email",
|
||||
"handle": "handle",
|
||||
"name": "name",
|
||||
"initialBgColor": "",
|
||||
"initialTextColor": "",
|
||||
"avatarUrl": "//system/attachment/avatar/0/original/avatar",
|
||||
},
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.PostForm.Add("handle", "handle")
|
||||
req.PostForm.Add("name", "name")
|
||||
|
||||
userService = &userServiceMocked{
|
||||
update: func(c context.Context, u *types.User) (*types.User, error) {
|
||||
return nil, service.UserErrInvalidEmail()
|
||||
},
|
||||
findByAny: func(ctx context.Context, i interface{}) (*types.User, error) {
|
||||
u := makeMockUser()
|
||||
u.ID = i.(uint64)
|
||||
|
||||
return u, nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "proc handle not unique",
|
||||
err: "",
|
||||
alerts: []request.Alert{{Type: "danger", Text: "profile.alerts.profile-update-fail", Html: ""}},
|
||||
link: GetLinks().Profile,
|
||||
payload: map[string]string{
|
||||
"email": "mockuser@example.tld",
|
||||
"error": "handle not unique",
|
||||
"handle": "handle",
|
||||
"name": "name",
|
||||
"initialBgColor": "",
|
||||
"initialTextColor": "",
|
||||
"avatarUrl": "//system/attachment/avatar/0/original/avatar",
|
||||
},
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.PostForm.Add("handle", "handle")
|
||||
req.PostForm.Add("name", "name")
|
||||
|
||||
userService = &userServiceMocked{
|
||||
update: func(c context.Context, u *types.User) (*types.User, error) {
|
||||
return nil, service.UserErrHandleNotUnique()
|
||||
},
|
||||
findByAny: func(ctx context.Context, i interface{}) (*types.User, error) {
|
||||
u := makeMockUser()
|
||||
u.ID = i.(uint64)
|
||||
|
||||
return u, nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user.errors.notAllowedToUpdate",
|
||||
err: "",
|
||||
alerts: []request.Alert{{Type: "danger", Text: "profile.alerts.profile-update-fail", Html: ""}},
|
||||
link: GetLinks().Profile,
|
||||
payload: map[string]string{
|
||||
"email": "mockuser@example.tld",
|
||||
"error": "not allowed to update this user",
|
||||
"handle": "handle",
|
||||
"name": "name",
|
||||
"initialBgColor": "",
|
||||
"initialTextColor": "",
|
||||
"avatarUrl": "//system/attachment/avatar/0/original/avatar",
|
||||
},
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.PostForm.Add("handle", "handle")
|
||||
req.PostForm.Add("name", "name")
|
||||
|
||||
userService = &userServiceMocked{
|
||||
update: func(c context.Context, u *types.User) (*types.User, error) {
|
||||
return nil, service.UserErrNotAllowedToUpdate()
|
||||
},
|
||||
findByAny: func(ctx context.Context, i interface{}) (*types.User, error) {
|
||||
u := makeMockUser()
|
||||
u.ID = i.(uint64)
|
||||
|
||||
return u, nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rq := require.New(t)
|
||||
|
||||
// reset from previous
|
||||
req.Form = url.Values{}
|
||||
req.PostForm = url.Values{}
|
||||
|
||||
authSettings := &settings.Settings{}
|
||||
|
||||
tc.fn(authSettings)
|
||||
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authHandlers.UserService = userService
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
err := authHandlers.profileProc(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(tc.template, authReq.Template)
|
||||
rq.Equal(tc.payload, authReq.GetKV())
|
||||
rq.Equal(tc.alerts, authReq.NewAlerts)
|
||||
rq.Equal(tc.link, authReq.RedirectTo)
|
||||
})
|
||||
}
|
||||
}
|
||||
35
server/auth/handlers/handle_saml.go
Normal file
35
server/auth/handlers/handle_saml.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/external"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (h *AuthHandlers) samlInit(w http.ResponseWriter, r *http.Request) {
|
||||
r = copyProviderToContext(r)
|
||||
h.Log.Info("starting SAML authentication flow")
|
||||
|
||||
if h.SamlSPService == nil || h.SamlSPService.Handler() == nil {
|
||||
h.Log.Error("SAML service not initialized")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
if !h.SamlSPService.Enabled {
|
||||
h.Log.Warn("failed to start SAML authentication flow: disabled")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
ex := external.NewSamlExternalHandler(h.SamlSPService)
|
||||
beginUserAuth(w, r, ex)
|
||||
|
||||
if user, err := completeUserAuth(w, r, ex); err != nil {
|
||||
h.Log.Error("failed to complete user auth", zap.Error(err))
|
||||
h.handleFailedExternalAuth(w, r, err)
|
||||
} else {
|
||||
h.handleSuccessfulExternalAuth(w, r, *user)
|
||||
}
|
||||
}
|
||||
69
server/auth/handlers/handle_security.go
Normal file
69
server/auth/handlers/handle_security.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (h *AuthHandlers) securityForm(req *request.AuthReq) error {
|
||||
req.Template = TmplSecurity
|
||||
|
||||
// user's MFA security policy
|
||||
umsp := req.AuthUser.User.Meta.SecurityPolicy.MFA
|
||||
|
||||
// Check if email OTP or TOTP is enforced (either by user settings or globally)
|
||||
req.Data["emailOtpEnforced"] = umsp.EnforcedEmailOTP || h.Settings.MultiFactor.EmailOTP.Enforced
|
||||
req.Data["totpEnforced"] = umsp.EnforcedTOTP || h.Settings.MultiFactor.TOTP.Enforced
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) securityProc(req *request.AuthReq) error {
|
||||
req.RedirectTo = GetLinks().Security
|
||||
|
||||
action := req.Request.Form.Get("action")
|
||||
switch action {
|
||||
case "reconfigureTOTP", "configureTOTP":
|
||||
// make sure secret is regenerated
|
||||
delete(req.Session.Values, totpSecretKey)
|
||||
|
||||
req.RedirectTo = GetLinks().MfaTotpNewSecret
|
||||
case "disableTOTP":
|
||||
req.RedirectTo = GetLinks().MfaTotpDisable
|
||||
|
||||
case "disableEmailOTP", "enableEmailOTP":
|
||||
enable := action == "enableEmailOTP"
|
||||
if user, err := h.AuthService.ConfigureEmailOTP(req.Context(), req.AuthUser.User.ID, enable); err != nil {
|
||||
return err
|
||||
} else {
|
||||
t := translator(req, "auth")
|
||||
|
||||
if enable {
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "primary",
|
||||
Text: t("security.template.mfa.email.enabled"),
|
||||
})
|
||||
} else {
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "primary",
|
||||
Text: t("security.template.mfa.email.disabled"),
|
||||
})
|
||||
}
|
||||
|
||||
// Make sure we update User's data in the session
|
||||
req.AuthUser.User = user
|
||||
req.AuthUser.Update(h.Settings, user)
|
||||
|
||||
// If enabling, mark as complete in the current session; required for future logins
|
||||
if enable {
|
||||
req.AuthUser.CompleteEmailOTP()
|
||||
}
|
||||
|
||||
req.AuthUser.Save(req.Session)
|
||||
|
||||
h.Log.Info("email OTP configured", zap.Bool("enabled", enable))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
159
server/auth/handlers/handle_security_test.go
Normal file
159
server/auth/handlers/handle_security_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/auth/settings"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_securityForm(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{
|
||||
URL: &url.URL{},
|
||||
}
|
||||
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
|
||||
authSettings = &settings.Settings{}
|
||||
|
||||
rq = require.New(t)
|
||||
)
|
||||
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
err := authHandlers.securityForm(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(TmplSecurity, authReq.Template)
|
||||
rq.Equal(true, authReq.Data["emailOtpEnforced"])
|
||||
rq.Equal(false, authReq.Data["totpEnforced"])
|
||||
}
|
||||
|
||||
func Test_securityProc(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{
|
||||
PostForm: url.Values{},
|
||||
}
|
||||
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
)
|
||||
|
||||
tcc := []testingExpect{
|
||||
{
|
||||
name: "reconfigureTOTP",
|
||||
link: GetLinks().MfaTotpNewSecret,
|
||||
payload: map[interface{}]interface{}{},
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.Form.Set("action", "reconfigureTOTP")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disableTOTP",
|
||||
link: GetLinks().MfaTotpDisable,
|
||||
payload: map[interface{}]interface{}{"totpSecret": "SECRET_VALUE"},
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.Form.Set("action", "disableTOTP")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disableEmailOTP failure",
|
||||
err: "custom error",
|
||||
link: GetLinks().Security,
|
||||
payload: map[interface{}]interface{}{"totpSecret": "SECRET_VALUE"},
|
||||
fn: func(_ *settings.Settings) {
|
||||
req.Form.Set("action", "disableEmailOTP")
|
||||
|
||||
authService = &authServiceMocked{
|
||||
configureEmailOTP: func(c context.Context, u uint64, b bool) (user *types.User, err error) {
|
||||
return nil, fmt.Errorf("custom error")
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rq := require.New(t)
|
||||
|
||||
// reset from previous
|
||||
req.Form = url.Values{}
|
||||
req.PostForm = url.Values{}
|
||||
|
||||
authSettings := &settings.Settings{}
|
||||
|
||||
tc.fn(authSettings)
|
||||
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
authReq.Session.Values = map[interface{}]interface{}{"totpSecret": "SECRET_VALUE"}
|
||||
|
||||
err := authHandlers.securityProc(authReq)
|
||||
|
||||
rq.Equal(tc.payload, authReq.Session.Values)
|
||||
rq.Equal(tc.link, authReq.RedirectTo)
|
||||
|
||||
if tc.alerts != nil {
|
||||
rq.Equal(tc.alerts, authReq.NewAlerts)
|
||||
}
|
||||
|
||||
if tc.err != "" {
|
||||
rq.Equal(tc.err, err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_securityProcDisableEmailOTPSuccess(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{
|
||||
Form: url.Values{},
|
||||
PostForm: url.Values{},
|
||||
}
|
||||
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
|
||||
authSettings = &settings.Settings{}
|
||||
|
||||
rq = require.New(t)
|
||||
)
|
||||
|
||||
req.Form.Set("action", "disableEmailOTP")
|
||||
|
||||
authService = &authServiceMocked{
|
||||
configureEmailOTP: func(c context.Context, u uint64, b bool) (*types.User, error) {
|
||||
return user, nil
|
||||
},
|
||||
}
|
||||
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
authReq.Session.Values = map[interface{}]interface{}{"totpSecret": "SECRET_VALUE"}
|
||||
|
||||
err := authHandlers.securityProc(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal([]request.Alert{{Type: "primary", Text: "security.template.mfa.email.disabled", Html: ""}}, authReq.NewAlerts)
|
||||
}
|
||||
136
server/auth/handlers/handle_sessions.go
Normal file
136
server/auth/handlers/handle_sessions.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
)
|
||||
|
||||
type (
|
||||
userSessions []userSession
|
||||
userSession struct {
|
||||
id string
|
||||
// this will store hashed ID value of the session ID + auth secret
|
||||
ID string
|
||||
Current bool
|
||||
ExpiresAt time.Time
|
||||
CreatedAt time.Time
|
||||
Expired bool
|
||||
ExpiresIn int
|
||||
RemoteAddr string
|
||||
SameRemoteAddr bool
|
||||
UserAgent string
|
||||
SameUserAgent bool
|
||||
}
|
||||
)
|
||||
|
||||
// Will sort sessions - current first, then the rest in order of creation
|
||||
var _ sort.Interface = userSessions{}
|
||||
|
||||
func (set userSessions) Len() int { return len(set) }
|
||||
func (set userSessions) Swap(i, j int) { set[i], set[j] = set[j], set[i] }
|
||||
func (set userSessions) Less(i, j int) bool {
|
||||
switch {
|
||||
case set[i].Current:
|
||||
return true
|
||||
default:
|
||||
return set[i].CreatedAt.After(set[j].CreatedAt)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) sessionsView(req *request.AuthReq) error {
|
||||
req.Template = TmplSessions
|
||||
|
||||
ss, err := h.getSessions(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sort.Sort(ss)
|
||||
req.Data["sessions"] = ss
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) sessionsProc(req *request.AuthReq) error {
|
||||
ctx := req.Context()
|
||||
ss, err := h.getSessions(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(req.Request.PostFormValue("delete-all-but-current")) > 0:
|
||||
for _, s := range ss {
|
||||
if s.Current {
|
||||
continue
|
||||
}
|
||||
|
||||
if err = h.SessionManager.DeleteByID(ctx, s.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = h.TokenService.DeleteByUserID(ctx, req.AuthUser.User.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "primary",
|
||||
Text: t("sessions.alerts.delete-sessions-but-current"),
|
||||
})
|
||||
case len(req.Request.PostFormValue("delete")) > 0:
|
||||
for _, s := range ss {
|
||||
if s.ID != req.Request.PostFormValue("delete") {
|
||||
continue
|
||||
}
|
||||
|
||||
if err = h.SessionManager.DeleteByID(ctx, s.id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "primary",
|
||||
Text: t("sessions.alerts.session-deleted"),
|
||||
})
|
||||
}
|
||||
|
||||
req.RedirectTo = GetLinks().Sessions
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) getSessions(req *request.AuthReq) (ss userSessions, err error) {
|
||||
var set []*types.AuthSession
|
||||
if set, err = h.SessionManager.Search(req.Context(), req.AuthUser.User.ID); err != nil {
|
||||
return
|
||||
} else {
|
||||
ss = make(userSessions, len(set))
|
||||
for i := range set {
|
||||
isCurrent := req.Session.ID == set[i].ID
|
||||
ss[i] = userSession{
|
||||
id: set[i].ID,
|
||||
ID: fmt.Sprintf("%x", md5.Sum([]byte(set[i].ID+h.Opt.Secret))),
|
||||
Current: isCurrent,
|
||||
CreatedAt: set[i].CreatedAt,
|
||||
ExpiresAt: set[i].ExpiresAt,
|
||||
Expired: set[i].ExpiresAt.Before(time.Now()),
|
||||
ExpiresIn: int(set[i].ExpiresAt.Sub(time.Now()).Hours() / 24),
|
||||
RemoteAddr: set[i].RemoteAddr,
|
||||
SameRemoteAddr: set[i].RemoteAddr == req.Request.RemoteAddr,
|
||||
UserAgent: set[i].UserAgent,
|
||||
SameUserAgent: set[i].UserAgent == req.Request.UserAgent(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
190
server/auth/handlers/handle_signup.go
Normal file
190
server/auth/handlers/handle_signup.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/pkg/logger"
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (h *AuthHandlers) signupForm(req *request.AuthReq) error {
|
||||
req.Template = TmplSignup
|
||||
req.Data["form"] = req.PopKV()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) signupProc(req *request.AuthReq) error {
|
||||
req.RedirectTo = GetLinks().Signup
|
||||
req.SetKV(nil)
|
||||
|
||||
ugID := uint64(0)
|
||||
if req.Client != nil && req.Client.Security != nil {
|
||||
ugID = req.Client.Security.UserGroup
|
||||
}
|
||||
|
||||
payload := &types.User{
|
||||
Email: req.Request.PostFormValue("email"),
|
||||
Handle: req.Request.PostFormValue("handle"),
|
||||
Name: req.Request.PostFormValue("name"),
|
||||
UserGroupID: ugID,
|
||||
}
|
||||
|
||||
newUser, err := h.AuthService.InternalSignUp(
|
||||
req.Context(),
|
||||
payload,
|
||||
req.Request.PostFormValue("password"),
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
if newUser.EmailConfirmed {
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "primary",
|
||||
Text: t("signup.alerts.signup-successful"),
|
||||
})
|
||||
|
||||
h.Log.Info(
|
||||
"signup successful",
|
||||
zap.String("email", newUser.Email),
|
||||
logger.Uint64s("roles", newUser.Roles()),
|
||||
)
|
||||
|
||||
// redirect to the webapp base path
|
||||
req.RedirectTo = WebappBasePath
|
||||
|
||||
// if the client is nil, redirect to the profile
|
||||
// else check if the client is trusted
|
||||
if req.Client == nil {
|
||||
req.RedirectTo = GetLinks().Profile
|
||||
} else if !req.Client.Trusted {
|
||||
req.RedirectTo = GetLinks().OAuth2AuthorizeClient
|
||||
}
|
||||
|
||||
req.AuthUser = request.NewAuthUser(h.Settings, newUser, false)
|
||||
|
||||
// auto-complete EmailOTP
|
||||
req.AuthUser.CompleteEmailOTP()
|
||||
|
||||
req.AuthUser.Save(req.Session)
|
||||
|
||||
} else {
|
||||
req.RedirectTo = GetLinks().PendingEmailConfirmation
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
fallback := func(req *request.AuthReq) {
|
||||
req.SetKV(map[string]string{
|
||||
"error": err.Error(),
|
||||
"email": payload.Email,
|
||||
"handle": payload.Handle,
|
||||
"name": payload.Name,
|
||||
})
|
||||
}
|
||||
|
||||
switch {
|
||||
case service.AuthErrInternalSignupDisabledByConfig().Is(err):
|
||||
h.signupDisabledAlert(req)
|
||||
return nil
|
||||
case service.AuthErrInvalidEmailFormat().Is(err),
|
||||
service.AuthErrInvalidHandle().Is(err),
|
||||
service.AuthErrPasswordNotSecure().Is(err),
|
||||
service.AuthErrInvalidCredentials().Is(err):
|
||||
fallback(req)
|
||||
|
||||
h.Log.Warn("handled error", zap.Error(err))
|
||||
return nil
|
||||
|
||||
default:
|
||||
fallback(req)
|
||||
|
||||
h.Log.Error("unhandled error", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) pendingEmailConfirmation(req *request.AuthReq) error {
|
||||
req.Template = TmplPendingEmailConfirmation
|
||||
|
||||
if _, has := req.Request.URL.Query()["resend"]; has && req.AuthUser.User != nil {
|
||||
err := h.AuthService.SendEmailAddressConfirmationToken(req.Context(), req.AuthUser.User)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) confirmEmail(req *request.AuthReq) (err error) {
|
||||
if token := req.Request.URL.Query().Get("token"); len(token) > 0 {
|
||||
var user *types.User
|
||||
user, err = h.AuthService.ValidateEmailConfirmationToken(req.Context(), token)
|
||||
if err == nil {
|
||||
// redirect back to self (but without token and with user in session
|
||||
h.Log.Debug("valid email confirmation token found, redirecting to profile")
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "primary",
|
||||
Text: t("signup.alerts.email-confirmed-logged-in"),
|
||||
})
|
||||
|
||||
// redirect to the webapp base path
|
||||
req.RedirectTo = WebappBasePath
|
||||
|
||||
if req.Client != nil && !req.Client.Trusted {
|
||||
req.RedirectTo = GetLinks().OAuth2AuthorizeClient
|
||||
}
|
||||
|
||||
req.AuthUser = request.NewAuthUser(h.Settings, user, false)
|
||||
|
||||
// auto-complete EmailOTP
|
||||
req.AuthUser.CompleteEmailOTP()
|
||||
|
||||
req.AuthUser.Save(req.Session)
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
h.Log.Warn("invalid email confirmation token used", zap.Error(err))
|
||||
|
||||
// redirect to the right page
|
||||
// not doing this here and relying on handler on subseq. request
|
||||
// will cause alerts to be removed
|
||||
if req.AuthUser == nil || req.AuthUser.User == nil {
|
||||
req.RedirectTo = GetLinks().Login
|
||||
} else {
|
||||
req.RedirectTo = GetLinks().Profile
|
||||
}
|
||||
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "warning",
|
||||
Text: t("signup.alerts.invalid-expired-token"),
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) onlyIfSignupEnabled(fn handlerFn) handlerFn {
|
||||
return func(req *request.AuthReq) error {
|
||||
if !h.Settings.SignupEnabled || !h.Settings.LocalEnabled {
|
||||
h.signupDisabledAlert(req)
|
||||
return nil
|
||||
}
|
||||
|
||||
return fn(req)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) signupDisabledAlert(req *request.AuthReq) {
|
||||
req.RedirectTo = GetLinks().Login
|
||||
t := translator(req, "auth")
|
||||
req.NewAlerts = append(req.NewAlerts, request.Alert{
|
||||
Type: "danger",
|
||||
Text: t("signup.alerts.signup-disabled"),
|
||||
})
|
||||
}
|
||||
241
server/auth/handlers/handle_signup_test.go
Normal file
241
server/auth/handlers/handle_signup_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/auth/settings"
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_signupForm(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{
|
||||
URL: &url.URL{},
|
||||
}
|
||||
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
|
||||
authSettings = &settings.Settings{}
|
||||
|
||||
rq = require.New(t)
|
||||
)
|
||||
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
userForm := map[string]string{
|
||||
"email": user.Email,
|
||||
"handle": user.Handle,
|
||||
"name": user.Name,
|
||||
}
|
||||
|
||||
authReq.SetKV(userForm)
|
||||
|
||||
err := authHandlers.signupForm(authReq)
|
||||
|
||||
rq.NoError(err)
|
||||
rq.Equal(TmplSignup, authReq.Template)
|
||||
rq.Equal(userForm, authReq.Data["form"])
|
||||
}
|
||||
|
||||
func Test_signupProc(t *testing.T) {
|
||||
var (
|
||||
user = makeMockUser()
|
||||
|
||||
req = &http.Request{
|
||||
PostForm: url.Values{},
|
||||
}
|
||||
|
||||
authService authService
|
||||
authHandlers *AuthHandlers
|
||||
authReq *request.AuthReq
|
||||
)
|
||||
|
||||
tcc := []testingExpect{
|
||||
{
|
||||
name: "success email confirmed",
|
||||
err: "",
|
||||
alerts: []request.Alert{{Type: "primary", Text: "signup.alerts.signup-successful", Html: ""}},
|
||||
link: GetLinks().Profile,
|
||||
payload: map[string]string(nil),
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
|
||||
u = &types.User{
|
||||
EmailConfirmed: true,
|
||||
Meta: &types.UserMeta{},
|
||||
}
|
||||
|
||||
u.Meta.SecurityPolicy.MFA.EnforcedEmailOTP = true
|
||||
u.Meta.SecurityPolicy.MFA.EnforcedTOTP = false
|
||||
|
||||
err = nil
|
||||
|
||||
return
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "success email unconfirmed",
|
||||
err: "",
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().PendingEmailConfirmation,
|
||||
payload: map[string]string(nil),
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
|
||||
return &types.User{EmailConfirmed: false}, nil
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "internal signup disabled",
|
||||
err: "",
|
||||
alerts: []request.Alert{{Type: "danger", Text: "signup.alerts.signup-disabled", Html: ""}},
|
||||
link: GetLinks().Login,
|
||||
payload: map[string]string(nil),
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
|
||||
return nil, service.AuthErrInternalSignupDisabledByConfig()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid email format",
|
||||
err: "",
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Signup,
|
||||
payload: map[string]string{"email": "", "error": "invalid email", "handle": "", "name": ""},
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
|
||||
return nil, service.AuthErrInvalidEmailFormat()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid handle",
|
||||
err: "",
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Signup,
|
||||
payload: map[string]string{"email": "", "error": "invalid handle", "handle": "", "name": ""},
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
|
||||
return nil, service.AuthErrInvalidHandle()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "password not secure",
|
||||
err: "",
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Signup,
|
||||
payload: map[string]string{"email": "", "error": "provided password is not secure; use longer password to follow the password policy", "handle": "", "name": ""},
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
|
||||
return nil, service.AuthErrPasswordNotSecure()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid credentials",
|
||||
err: "",
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Signup,
|
||||
payload: map[string]string{"email": "", "error": "invalid username and password combination", "handle": "", "name": ""},
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
|
||||
return nil, service.AuthErrInvalidCredentials()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "handle not unique",
|
||||
err: "handle not unique",
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Signup,
|
||||
payload: map[string]string{"email": "test@mail.com", "error": "handle not unique", "handle": "test-user", "name": "Test User"},
|
||||
postForm: url.Values{
|
||||
"email": {"test@mail.com"},
|
||||
"handle": {"test-user"},
|
||||
"name": {"Test User"},
|
||||
},
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
|
||||
return nil, service.UserErrHandleNotUnique()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "email not unique",
|
||||
err: "email not unique",
|
||||
alerts: []request.Alert(nil),
|
||||
link: GetLinks().Signup,
|
||||
payload: map[string]string{"email": "test@mail.com", "error": "email not unique", "handle": "test-user", "name": "Test User"},
|
||||
postForm: url.Values{
|
||||
"email": {"test@mail.com"},
|
||||
"handle": {"test-user"},
|
||||
"name": {"Test User"},
|
||||
},
|
||||
fn: func(_ *settings.Settings) {
|
||||
authService = &authServiceMocked{
|
||||
internalSignUp: func(c context.Context, user *types.User, s string) (u *types.User, err error) {
|
||||
return nil, service.UserErrEmailNotUnique()
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rq := require.New(t)
|
||||
|
||||
// reset from previous
|
||||
req.Form = url.Values{}
|
||||
req.PostForm = tc.postForm
|
||||
|
||||
authSettings := &settings.Settings{}
|
||||
|
||||
tc.fn(authSettings)
|
||||
|
||||
authHandlers = prepareClientAuthHandlers(authService, authSettings)
|
||||
authReq = prepareClientAuthReq(authHandlers, req, user)
|
||||
|
||||
err := authHandlers.signupProc(authReq)
|
||||
|
||||
if len(tc.err) > 0 {
|
||||
rq.Equal(tc.err, err.Error())
|
||||
} else {
|
||||
rq.NoError(err)
|
||||
}
|
||||
rq.Equal(tc.template, authReq.Template)
|
||||
rq.Equal(tc.payload, authReq.GetKV())
|
||||
rq.Equal(tc.alerts, authReq.NewAlerts)
|
||||
rq.Equal(tc.link, authReq.RedirectTo)
|
||||
})
|
||||
}
|
||||
}
|
||||
537
server/auth/handlers/handler.go
Normal file
537
server/auth/handlers/handler.go
Normal file
@@ -0,0 +1,537 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/external"
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/auth/saml"
|
||||
"github.com/cortezaproject/corteza/server/auth/settings"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/pkg/errors"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/go-oauth2/oauth2/v4"
|
||||
"github.com/go-oauth2/oauth2/v4/server"
|
||||
"github.com/gorilla/csrf"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/markbates/goth"
|
||||
"github.com/spf13/cast"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type (
|
||||
authService interface {
|
||||
External(ctx context.Context, profile types.ExternalAuthUser) (u *types.User, err error)
|
||||
InternalSignUp(ctx context.Context, input *types.User, password string) (u *types.User, err error)
|
||||
InternalLogin(ctx context.Context, email string, password string) (u *types.User, err error)
|
||||
SetPassword(ctx context.Context, userID uint64, password string) (err error)
|
||||
ChangePassword(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error)
|
||||
ValidateEmailConfirmationToken(ctx context.Context, token string) (user *types.User, err error)
|
||||
ValidatePasswordResetToken(ctx context.Context, token string) (user *types.User, err error)
|
||||
ValidatePasswordCreateToken(ctx context.Context, token string) (user *types.User, err error)
|
||||
SendEmailAddressConfirmationToken(ctx context.Context, u *types.User) (err error)
|
||||
SendPasswordResetToken(ctx context.Context, email string) (err error)
|
||||
GetProviders() types.ExternalAuthProviderSet
|
||||
PasswordSet(ctx context.Context, email string) (is bool)
|
||||
ValidateTOTP(ctx context.Context, code string) (err error)
|
||||
ConfigureTOTP(ctx context.Context, secret string, code string) (u *types.User, err error)
|
||||
RemoveTOTP(ctx context.Context, userID uint64, code string) (u *types.User, err error)
|
||||
LoadRoleMemberships(ctx context.Context, u *types.User) error
|
||||
|
||||
SendEmailOTP(ctx context.Context) (err error)
|
||||
ConfigureEmailOTP(ctx context.Context, userID uint64, enable bool) (u *types.User, err error)
|
||||
ValidateEmailOTP(ctx context.Context, code string) (err error)
|
||||
SendInviteEmail(ctx context.Context, email string) (err error)
|
||||
ValidateInviteEmailToken(ctx context.Context, token string) (user *types.User, err error)
|
||||
}
|
||||
|
||||
credentialsService interface {
|
||||
List(ctx context.Context, userID uint64) (cc types.CredentialSet, err error)
|
||||
Create(ctx context.Context, c *types.Credential) (*types.Credential, error)
|
||||
Update(ctx context.Context, c *types.Credential) (*types.Credential, error)
|
||||
Delete(ctx context.Context, userID, credentialsID uint64) (err error)
|
||||
}
|
||||
|
||||
userService interface {
|
||||
FindByAny(ctx context.Context, identifier interface{}) (*types.User, error)
|
||||
Update(context.Context, *types.User) (*types.User, error)
|
||||
UploadAvatar(ctx context.Context, userID uint64, upload *multipart.FileHeader) (err error)
|
||||
GenerateAvatar(ctx context.Context, userID uint64, bgColor string, initialColor string) (err error)
|
||||
DeleteAvatar(ctx context.Context, userID uint64) (err error)
|
||||
}
|
||||
|
||||
clientService interface {
|
||||
Lookup(context.Context, interface{}) (*types.AuthClient, error)
|
||||
Confirmed(context.Context, uint64) (types.AuthConfirmedClientSet, error)
|
||||
Revoke(ctx context.Context, userID, clientID uint64) error
|
||||
}
|
||||
|
||||
// @todo this should probably be a little more decoupled from the store and nicely named
|
||||
tokenService interface {
|
||||
SearchByUserID(ctx context.Context, userID uint64) (types.AuthOa2tokenSet, error)
|
||||
DeleteByID(ctx context.Context, ID uint64) error
|
||||
DeleteByUserID(ctx context.Context, userID uint64) error
|
||||
}
|
||||
|
||||
templateExecutor interface {
|
||||
ExecuteTemplate(io.Writer, string, interface{}, ...func(template *template.Template) *template.Template) error
|
||||
}
|
||||
|
||||
oauth2Service interface {
|
||||
GetRedirectURI(req *server.AuthorizeRequest, data map[string]interface{}) (string, error)
|
||||
CheckResponseType(rt oauth2.ResponseType) bool
|
||||
CheckCodeChallengeMethod(ccm oauth2.CodeChallengeMethod) bool
|
||||
ValidationAuthorizeRequest(r *http.Request) (*server.AuthorizeRequest, error)
|
||||
GetAuthorizeToken(ctx context.Context, req *server.AuthorizeRequest) (oauth2.TokenInfo, error)
|
||||
GetAuthorizeData(rt oauth2.ResponseType, ti oauth2.TokenInfo) map[string]interface{}
|
||||
HandleAuthorizeRequest(w http.ResponseWriter, r *http.Request) error
|
||||
ValidationTokenRequest(r *http.Request) (oauth2.GrantType, *oauth2.TokenGenerateRequest, error)
|
||||
CheckGrantType(gt oauth2.GrantType) bool
|
||||
GetAccessToken(ctx context.Context, gt oauth2.GrantType, tgr *oauth2.TokenGenerateRequest) (oauth2.TokenInfo, error)
|
||||
GetTokenData(ti oauth2.TokenInfo) map[string]interface{}
|
||||
HandleTokenRequest(w http.ResponseWriter, r *http.Request) error
|
||||
GetErrorData(err error) (map[string]interface{}, int, http.Header)
|
||||
BearerAuth(r *http.Request) (string, bool)
|
||||
ValidationBearerToken(r *http.Request) (oauth2.TokenInfo, error)
|
||||
}
|
||||
|
||||
localeService interface {
|
||||
NS(ctx context.Context, ns string) func(key string, rr ...string) string
|
||||
T(ctx context.Context, ns, key string, rr ...string) string
|
||||
HasLanguage(lang language.Tag) bool
|
||||
LocalizedList(ctx context.Context) []*locale.Language
|
||||
}
|
||||
|
||||
AuthHandlers struct {
|
||||
Log *zap.Logger
|
||||
|
||||
Locale localeService
|
||||
Templates templateExecutor
|
||||
OAuth2 oauth2Service
|
||||
SessionManager *request.SessionManager
|
||||
AuthService authService
|
||||
CredentialsService credentialsService
|
||||
UserService userService
|
||||
ClientService clientService
|
||||
TokenService tokenService
|
||||
DefaultClient *types.AuthClient
|
||||
Opt options.AuthOpt
|
||||
Settings *settings.Settings
|
||||
SamlSPService *saml.SamlSPService
|
||||
Attachment service.AttachmentService
|
||||
}
|
||||
|
||||
handlerFn func(req *request.AuthReq) error
|
||||
)
|
||||
|
||||
const (
|
||||
TmplAuthorizedClients = "authorized-clients.html.tpl"
|
||||
TmplChangePassword = "change-password.html.tpl"
|
||||
TmplCreatePassword = "create-password.html.tpl"
|
||||
TmplLogin = "login.html.tpl"
|
||||
TmplLogout = "logout.html.tpl"
|
||||
TmplOAuth2AuthorizeClient = "oauth2-authorize-client.html.tpl"
|
||||
TmplRequestPasswordReset = "request-password-reset.html.tpl"
|
||||
TmplPasswordResetRequested = "password-reset-requested.html.tpl"
|
||||
TmplResetPassword = "reset-password.html.tpl"
|
||||
TmplInvite = "invite.html.tpl"
|
||||
TmplSecurity = "security.html.tpl"
|
||||
TmplProfile = "profile.html.tpl"
|
||||
TmplSessions = "sessions.html.tpl"
|
||||
TmplSignup = "signup.html.tpl"
|
||||
TmplPendingEmailConfirmation = "pending-email-confirmation.html.tpl"
|
||||
TmplMfa = "mfa.html.tpl"
|
||||
TmplMfaTotp = "mfa-totp.html.tpl"
|
||||
TmplMfaTotpDisable = "mfa-totp-disable.html.tpl"
|
||||
TmplInternalError = "error-internal.html.tpl"
|
||||
|
||||
// 1k of data per POST field is all we allow
|
||||
maxPostValueLength = 2 << 9
|
||||
|
||||
// general limitation on number of fields
|
||||
maxPostFields = 10
|
||||
)
|
||||
|
||||
var (
|
||||
// wrapper around time.Now() that will aid service testing
|
||||
now = func() *time.Time {
|
||||
c := time.Now()
|
||||
return &c
|
||||
}
|
||||
)
|
||||
|
||||
func init() {
|
||||
gob.Register(&types.User{})
|
||||
gob.Register(&types.AuthClient{})
|
||||
gob.Register([]request.Alert{})
|
||||
gob.Register(url.Values{})
|
||||
}
|
||||
|
||||
// handles auth request and prepares request struct with request, session and response helper
|
||||
func (h *AuthHandlers) handle(fn handlerFn) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var (
|
||||
req = &request.AuthReq{
|
||||
Response: w,
|
||||
Request: r,
|
||||
Data: make(map[string]interface{}),
|
||||
NewAlerts: make([]request.Alert, 0),
|
||||
PrevAlerts: make([]request.Alert, 0),
|
||||
Session: h.SessionManager.Get(r),
|
||||
Locale: h.Locale,
|
||||
}
|
||||
)
|
||||
|
||||
h.Log.Debug(
|
||||
"handling request",
|
||||
zap.String("url", r.RequestURI),
|
||||
zap.String("method", r.Method),
|
||||
)
|
||||
|
||||
err := func() (err error) {
|
||||
if err = r.ParseForm(); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Caching 32MB to memory, the rest to disk
|
||||
err = r.ParseMultipartForm(32 << 20)
|
||||
if err != nil && err != http.ErrNotMultipart {
|
||||
return
|
||||
}
|
||||
|
||||
if !validFormPost(r) {
|
||||
req.Status = http.StatusRequestEntityTooLarge
|
||||
return
|
||||
}
|
||||
|
||||
req.Client = request.GetOauth2Client(req.Session)
|
||||
|
||||
req.AuthUser = request.GetAuthUser(req.Session)
|
||||
|
||||
// make sure user (identity) is part of the context
|
||||
// so we can properly identify ourselves when interacting
|
||||
// with services, and set user's preferred language
|
||||
if req.AuthUser != nil && !req.AuthUser.PendingMFA() {
|
||||
req.Request = req.Request.Clone(auth.SetIdentityToContext(
|
||||
req.Context(),
|
||||
auth.Authenticated(req.AuthUser.User.ID, req.AuthUser.User.Roles()...),
|
||||
))
|
||||
|
||||
userPreferredLanguage := language.Make(req.AuthUser.User.Meta.PreferredLanguage)
|
||||
|
||||
// set user's preferred language
|
||||
if h.Locale.HasLanguage(userPreferredLanguage) {
|
||||
ctx := req.Request.Context()
|
||||
ctx = locale.SetAcceptLanguageToContext(ctx, userPreferredLanguage)
|
||||
ctx = locale.SetContentLanguageToContext(ctx, userPreferredLanguage)
|
||||
|
||||
req.Request = req.Request.WithContext(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Alerts show for 1 session only!
|
||||
req.PrevAlerts = req.PopAlerts()
|
||||
if err = fn(req); err != nil {
|
||||
h.Log.Error("error in handler", zap.Error(err))
|
||||
}
|
||||
|
||||
if req.RedirectTo != "" && len(req.PrevAlerts) > 0 {
|
||||
// redirect happened, so probably none noticed alerts
|
||||
// lets push them at the end of new alerts
|
||||
req.NewAlerts = append(req.NewAlerts, req.PrevAlerts...)
|
||||
}
|
||||
|
||||
if len(req.NewAlerts) > 0 {
|
||||
req.SetAlerts(req.NewAlerts...)
|
||||
}
|
||||
|
||||
if err = sessions.Save(r, w); err != nil {
|
||||
h.Log.Error("could not save session", zap.Error(err))
|
||||
}
|
||||
|
||||
if req.Status == 0 {
|
||||
switch {
|
||||
case req.RedirectTo != "":
|
||||
req.Status = http.StatusSeeOther
|
||||
req.Template = ""
|
||||
case req.Template != "":
|
||||
req.Status = http.StatusOK
|
||||
default:
|
||||
req.Status = http.StatusInternalServerError
|
||||
req.Template = TmplInternalError
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}()
|
||||
|
||||
if err == nil {
|
||||
if req.Status >= 300 && req.Status < 400 {
|
||||
// redirect, nothing special to handle
|
||||
http.Redirect(w, r, req.RedirectTo, req.Status)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Status > 0 {
|
||||
// in cases when something else already wrote the status
|
||||
w.WriteHeader(req.Status)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// translator template function
|
||||
//
|
||||
// we're adding these functions on every request on all templates
|
||||
// because we need request's context to detect the language from!
|
||||
ttf = func(t *template.Template) *template.Template {
|
||||
return t.Funcs(map[string]interface{}{
|
||||
"language": func() string { return locale.GetAcceptLanguageFromContext(req.Context()).String() },
|
||||
"tr": func(key string, pp ...interface{}) template.HTML {
|
||||
ss := make([]string, len(pp))
|
||||
for i := range pp {
|
||||
ss[i] = cast.ToString(pp[i])
|
||||
}
|
||||
|
||||
return template.HTML(h.Locale.T(req.Context(), "auth", key, ss...))
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
// Handling just text/html response types from here on
|
||||
//
|
||||
// If handler does not wish to use the template leave/set it to "" (empty string)
|
||||
if err == nil && req.Template != "" {
|
||||
err = h.Templates.ExecuteTemplate(w,
|
||||
req.Template,
|
||||
h.enrichTmplData(req),
|
||||
ttf,
|
||||
)
|
||||
|
||||
h.Log.Debug("template executed", zap.String("name", req.Template), zap.Error(err))
|
||||
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
err = h.Templates.ExecuteTemplate(w, TmplInternalError, map[string]interface{}{
|
||||
"error": err,
|
||||
}, ttf)
|
||||
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
h.Log.Error("unhandled error", zap.Error(err))
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add alerts, settings, providers, csrf token, Bg
|
||||
func (h *AuthHandlers) enrichTmplData(req *request.AuthReq) interface{} {
|
||||
d := req.Data
|
||||
d["theme"] = "light"
|
||||
if req.AuthUser != nil {
|
||||
maskEmail := service.CurrentSettings.Privacy.Mask.Email
|
||||
maskName := service.CurrentSettings.Privacy.Mask.Name
|
||||
|
||||
service.CurrentSettings.Privacy.Mask.Email = false
|
||||
service.CurrentSettings.Privacy.Mask.Name = false
|
||||
|
||||
// fetch current user with updated fields
|
||||
user, err := h.UserService.FindByAny(req.Context(), req.AuthUser.User.ID)
|
||||
|
||||
service.CurrentSettings.Privacy.Mask.Email = maskEmail
|
||||
service.CurrentSettings.Privacy.Mask.Name = maskName
|
||||
|
||||
// check if err is not nil and cater for MFA by checking if the error is not allowed to read
|
||||
if err != nil && !errors.Is(err, service.UserErrNotAllowedToRead()) {
|
||||
return err
|
||||
}
|
||||
|
||||
d["user"] = user
|
||||
d["theme"] = user.Meta.Theme
|
||||
}
|
||||
|
||||
if req.Client != nil {
|
||||
c := authClient{
|
||||
ID: req.Client.ID,
|
||||
Name: req.Client.Handle,
|
||||
}
|
||||
|
||||
if req.Client.Meta != nil {
|
||||
c.Name = req.Client.Meta.Name
|
||||
c.Description = req.Client.Meta.Description
|
||||
}
|
||||
|
||||
d["client"] = c
|
||||
}
|
||||
|
||||
d[csrf.TemplateTag] = csrf.TemplateField(req.Request)
|
||||
|
||||
// In case we did not redirect, join previous alerts with new ones
|
||||
d["alerts"] = append(req.PrevAlerts, req.NewAlerts...)
|
||||
|
||||
dSettings := *h.Settings
|
||||
|
||||
var pp = make([]provider, 0, len(dSettings.Providers))
|
||||
|
||||
if h.Settings.Saml.Enabled {
|
||||
pp = append(pp, provider(saml.TemplateProvider(h.Settings.Saml.IDP.URL, h.Settings.Saml.Name)))
|
||||
}
|
||||
|
||||
for _, p := range dSettings.Providers {
|
||||
if _, err := goth.GetProvider(p.Handle); err != nil {
|
||||
continue
|
||||
}
|
||||
// Skipping the ones we don't use for identity
|
||||
if !p.HasUsage(types.ExternalProviderUsageIdentity) {
|
||||
continue
|
||||
}
|
||||
|
||||
out := provider{
|
||||
Label: p.Label,
|
||||
Handle: p.Handle,
|
||||
Icon: p.Handle,
|
||||
}
|
||||
|
||||
if strings.HasPrefix(out.Icon, external.OIDC_PROVIDER_PREFIX) {
|
||||
out.Icon = "key"
|
||||
}
|
||||
|
||||
pp = append(pp, out)
|
||||
}
|
||||
|
||||
d["providers"] = pp
|
||||
|
||||
dSettings.Providers = nil
|
||||
d["settings"] = dSettings
|
||||
|
||||
d["authBg"] = template.CSS(h.bgStylesData())
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func (h *AuthHandlers) bgStylesData() string {
|
||||
if h.Settings.BackgroundUI.BackgroundImageSrcUrl == "" {
|
||||
return fmt.Sprintf("background: url(%s/release-background.jpg) no-repeat top; %s",
|
||||
GetLinks().Assets, h.Settings.BackgroundUI.Styles)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("background: url('%s') no-repeat top; %s",
|
||||
h.Settings.BackgroundUI.BackgroundImageSrcUrl, h.Settings.BackgroundUI.Styles)
|
||||
}
|
||||
|
||||
// Handle successful auth (on any factor)
|
||||
func handleSuccessfulAuth(req *request.AuthReq) {
|
||||
switch {
|
||||
case req.AuthUser.PendingMFA():
|
||||
req.RedirectTo = GetLinks().Mfa
|
||||
|
||||
case request.GetOAuth2AuthParams(req.Session) != nil:
|
||||
// client authorization flow was paused, continue.
|
||||
req.RedirectTo = GetLinks().OAuth2AuthorizeClient
|
||||
|
||||
default:
|
||||
// Always go to profile
|
||||
req.RedirectTo = GetLinks().Profile
|
||||
}
|
||||
}
|
||||
|
||||
// redirects anonymous users to login
|
||||
func authOnly(fn handlerFn) handlerFn {
|
||||
return func(req *request.AuthReq) error {
|
||||
// these next few lines keep users away from the pages they should not see
|
||||
// and redirect them to where they need to be
|
||||
switch {
|
||||
case req.AuthUser == nil || req.AuthUser.User == nil:
|
||||
// not authenticated at all, move to login
|
||||
req.RedirectTo = GetLinks().Login
|
||||
|
||||
case req.AuthUser.UnconfiguredTOTP():
|
||||
// authenticated but need to configure MFA
|
||||
req.RedirectTo = GetLinks().MfaTotpNewSecret
|
||||
|
||||
case req.AuthUser.PendingMFA():
|
||||
// authenticated but MFA pending
|
||||
req.RedirectTo = GetLinks().Mfa
|
||||
|
||||
default:
|
||||
return fn(req)
|
||||
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func partAuthOnly(fn handlerFn) handlerFn {
|
||||
return func(req *request.AuthReq) error {
|
||||
if req.AuthUser == nil || req.AuthUser.User == nil {
|
||||
req.RedirectTo = GetLinks().Login
|
||||
return nil
|
||||
} else {
|
||||
return fn(req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// redirects authenticated users to profile
|
||||
func anonyOnly(fn handlerFn) handlerFn {
|
||||
return func(req *request.AuthReq) error {
|
||||
if req.AuthUser != nil && req.AuthUser.User != nil {
|
||||
req.RedirectTo = GetLinks().Profile
|
||||
return nil
|
||||
} else {
|
||||
return fn(req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func translator(req *request.AuthReq, ns string) func(key string, rr ...string) string {
|
||||
return req.Locale.NS(req.Context(), ns)
|
||||
}
|
||||
|
||||
// general validation of posted data
|
||||
//
|
||||
// quite primitive for now but should be effective against out-of-bounds attacks
|
||||
//
|
||||
// in the future, more sophisticated validation might be needed
|
||||
func validFormPost(r *http.Request) bool {
|
||||
if len(r.Form) > maxPostFields {
|
||||
// auth does not have any large forms
|
||||
return false
|
||||
}
|
||||
|
||||
// None of the values from the post fields should be longer than max length
|
||||
for k, _ := range r.Form {
|
||||
if len(r.Form[k]) > 1 {
|
||||
// assuming only one value per field!
|
||||
return false
|
||||
}
|
||||
|
||||
if len(r.Form[k][0]) > maxPostValueLength {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
122
server/auth/handlers/links.go
Normal file
122
server/auth/handlers/links.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
Links struct {
|
||||
Profile,
|
||||
Signup,
|
||||
ConfirmEmail,
|
||||
PendingEmailConfirmation,
|
||||
Login,
|
||||
|
||||
Security,
|
||||
ChangePassword,
|
||||
CreatePassword,
|
||||
|
||||
RequestPasswordReset,
|
||||
PasswordResetRequested,
|
||||
ResetPassword,
|
||||
AcceptInvite,
|
||||
Sessions,
|
||||
AuthorizedClients,
|
||||
Logout,
|
||||
|
||||
OAuth2Authorize,
|
||||
OAuth2AuthorizeClient,
|
||||
OAuth2Token,
|
||||
OAuth2Info,
|
||||
OAuth2UserInfo,
|
||||
OAuth2DefaultClient,
|
||||
OAuth2PublicKeys,
|
||||
|
||||
Mfa,
|
||||
|
||||
MfaTotpNewSecret,
|
||||
MfaTotpQRImage,
|
||||
MfaTotpDisable,
|
||||
|
||||
External,
|
||||
|
||||
SamlInit,
|
||||
SamlCallback,
|
||||
SamlMetadata,
|
||||
SamlLogout,
|
||||
|
||||
Base,
|
||||
|
||||
Assets,
|
||||
|
||||
AuthAssets string
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
invalidLinkChars = regexp.MustCompile(`[^-A-Za-z0-9+&@#/%?=~_|!:,.;\\(\\)]`)
|
||||
stripSchema = regexp.MustCompile(`(.*\/\/)`)
|
||||
BasePath string = "/"
|
||||
WebappBasePath string = "/"
|
||||
)
|
||||
|
||||
func GetLinks() Links {
|
||||
var b = strings.TrimSuffix(BasePath, "/") + "/"
|
||||
|
||||
return Links{
|
||||
Profile: b + "auth",
|
||||
Signup: b + "auth/signup",
|
||||
ConfirmEmail: b + "auth/confirm-email",
|
||||
PendingEmailConfirmation: b + "auth/pending-email-confirmation",
|
||||
Login: b + "auth/login",
|
||||
Security: b + "auth/security",
|
||||
ChangePassword: b + "auth/change-password",
|
||||
CreatePassword: b + "auth/create-password",
|
||||
RequestPasswordReset: b + "auth/request-password-reset",
|
||||
PasswordResetRequested: b + "auth/password-reset-requested",
|
||||
ResetPassword: b + "auth/reset-password",
|
||||
AcceptInvite: b + "auth/accept-invite",
|
||||
Sessions: b + "auth/sessions",
|
||||
AuthorizedClients: b + "auth/authorized-clients",
|
||||
Logout: b + "auth/logout",
|
||||
|
||||
OAuth2Authorize: b + "auth/oauth2/authorize",
|
||||
OAuth2AuthorizeClient: b + "auth/oauth2/authorize-client",
|
||||
OAuth2Token: b + "auth/oauth2/token",
|
||||
OAuth2Info: b + "auth/oauth2/info",
|
||||
OAuth2UserInfo: b + "auth/oauth2/userinfo",
|
||||
OAuth2DefaultClient: b + "auth/oauth2/default-client",
|
||||
OAuth2PublicKeys: b + "auth/oauth2/public-keys",
|
||||
|
||||
Mfa: b + "auth/mfa",
|
||||
MfaTotpNewSecret: b + "auth/mfa/totp/setup",
|
||||
MfaTotpQRImage: b + "auth/mfa/totp/qr.png",
|
||||
MfaTotpDisable: b + "auth/mfa/totp/disable",
|
||||
|
||||
External: b + "auth/external",
|
||||
|
||||
SamlInit: b + "auth/external/saml/init",
|
||||
SamlCallback: b + "auth/external/saml/callback",
|
||||
SamlMetadata: b + "auth/external/saml/metadata",
|
||||
SamlLogout: b + "auth/external/saml/slo",
|
||||
|
||||
Assets: b + "assets",
|
||||
AuthAssets: b + "auth/assets/public",
|
||||
Base: b,
|
||||
}
|
||||
}
|
||||
|
||||
// trim base path
|
||||
func tbp(s string) string {
|
||||
s = strings.TrimPrefix(s, BasePath)
|
||||
if !strings.HasPrefix(s, "/") {
|
||||
s = "/" + s
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func sanitizeLink(l string) string {
|
||||
return `//` + stripSchema.ReplaceAllString(invalidLinkChars.ReplaceAllString(l, ""), "")
|
||||
}
|
||||
41
server/auth/handlers/links_test.go
Normal file
41
server/auth/handlers/links_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_sanitizeLink(t *testing.T) {
|
||||
type (
|
||||
tt struct {
|
||||
name string
|
||||
link string
|
||||
expect string
|
||||
}
|
||||
)
|
||||
|
||||
tcc := []tt{
|
||||
{
|
||||
name: `empty link`,
|
||||
link: ``,
|
||||
expect: `//`,
|
||||
},
|
||||
{
|
||||
name: `Example URL with query`,
|
||||
link: `https://example.url/query`,
|
||||
expect: `//example.url/query`,
|
||||
},
|
||||
{
|
||||
name: `URL with additional js`,
|
||||
link: `javascript:window.alert('foobar')`,
|
||||
expect: `//javascript:window.alert(foobar)`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tcc {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.New(t).Equal(tc.expect, sanitizeLink(tc.link))
|
||||
})
|
||||
}
|
||||
}
|
||||
405
server/auth/handlers/mock_test.go
Normal file
405
server/auth/handlers/mock_test.go
Normal file
@@ -0,0 +1,405 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go.uber.org/zap"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/auth/request"
|
||||
"github.com/cortezaproject/corteza/server/auth/settings"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/cortezaproject/corteza/server/pkg/options"
|
||||
"github.com/cortezaproject/corteza/server/store"
|
||||
"github.com/cortezaproject/corteza/server/system/service"
|
||||
"github.com/cortezaproject/corteza/server/system/types"
|
||||
"github.com/go-oauth2/oauth2/v4"
|
||||
"github.com/go-oauth2/oauth2/v4/server"
|
||||
"github.com/gorilla/sessions"
|
||||
)
|
||||
|
||||
type (
|
||||
mockAuthService struct {
|
||||
authService
|
||||
|
||||
store store.Storer
|
||||
settings *types.AppSettings
|
||||
notifications service.AuthNotificationService
|
||||
|
||||
providerValidator func(string) error
|
||||
}
|
||||
|
||||
mockNotificationService struct {
|
||||
settings *types.AppSettings
|
||||
opt options.AuthOpt
|
||||
}
|
||||
|
||||
mockSession struct {
|
||||
Options *sessions.Options
|
||||
get func(r *http.Request, name string) (*sessions.Session, error)
|
||||
new func(r *http.Request, name string) (*sessions.Session, error)
|
||||
save func(r *http.Request, w http.ResponseWriter, s *sessions.Session) error
|
||||
}
|
||||
|
||||
oauth2ServiceMocked struct {
|
||||
getRedirectURI func(req *server.AuthorizeRequest, data map[string]interface{}) (string, error)
|
||||
checkResponseType func(rt oauth2.ResponseType) bool
|
||||
checkCodeChallengeMethod func(ccm oauth2.CodeChallengeMethod) bool
|
||||
validationAuthorizeRequest func(r *http.Request) (*server.AuthorizeRequest, error)
|
||||
getAuthorizeToken func(ctx context.Context, req *server.AuthorizeRequest) (oauth2.TokenInfo, error)
|
||||
getAuthorizeData func(rt oauth2.ResponseType, ti oauth2.TokenInfo) map[string]interface{}
|
||||
handleAuthorizeRequest func(w http.ResponseWriter, r *http.Request) error
|
||||
validationTokenRequest func(r *http.Request) (oauth2.GrantType, *oauth2.TokenGenerateRequest, error)
|
||||
checkGrantType func(gt oauth2.GrantType) bool
|
||||
getAccessToken func(ctx context.Context, gt oauth2.GrantType, tgr *oauth2.TokenGenerateRequest) (oauth2.TokenInfo, error)
|
||||
getTokenData func(ti oauth2.TokenInfo) map[string]interface{}
|
||||
handleTokenRequest func(w http.ResponseWriter, r *http.Request) error
|
||||
getErrorData func(err error) (map[string]interface{}, int, http.Header)
|
||||
bearerAuth func(r *http.Request) (string, bool)
|
||||
validationBearerToken func(r *http.Request) (oauth2.TokenInfo, error)
|
||||
}
|
||||
|
||||
testingExpect struct {
|
||||
name string
|
||||
payload interface{}
|
||||
link string
|
||||
err string
|
||||
template string
|
||||
alerts []request.Alert
|
||||
userService userService
|
||||
postForm url.Values
|
||||
fn func(*settings.Settings)
|
||||
}
|
||||
|
||||
userServiceMocked struct {
|
||||
update func(context.Context, *types.User) (*types.User, error)
|
||||
findByAny func(context.Context, interface{}) (*types.User, error)
|
||||
uploadAvatar func(context.Context, uint64, *multipart.FileHeader) error
|
||||
generateAvatar func(context.Context, uint64, string, string) error
|
||||
deleteAvatar func(context.Context, uint64) error
|
||||
}
|
||||
|
||||
authServiceMocked struct {
|
||||
external func(context.Context, types.ExternalAuthUser) (u *types.User, err error)
|
||||
internalSignUp func(context.Context, *types.User, string) (u *types.User, err error)
|
||||
internalLogin func(context.Context, string, string) (u *types.User, err error)
|
||||
setPassword func(context.Context, uint64, string) (err error)
|
||||
changePassword func(context.Context, uint64, string, string) (err error)
|
||||
createPassword func(context.Context, uint64, string) (err error)
|
||||
validateEmailConfirmationToken func(context.Context, string) (user *types.User, err error)
|
||||
validatePasswordResetToken func(context.Context, string) (user *types.User, err error)
|
||||
validatePasswordCreateToken func(context.Context, string) (user *types.User, err error)
|
||||
sendEmailAddressConfirmationToken func(context.Context, *types.User) (err error)
|
||||
sendPasswordResetToken func(context.Context, string) (err error)
|
||||
passwordSet func(context.Context, string) bool
|
||||
getProviders func() types.ExternalAuthProviderSet
|
||||
validateTOTP func(context.Context, string) (err error)
|
||||
configureTOTP func(context.Context, string, string) (u *types.User, err error)
|
||||
removeTOTP func(context.Context, uint64, string) (u *types.User, err error)
|
||||
sendEmailOTP func(context.Context) (err error)
|
||||
configureEmailOTP func(context.Context, uint64, bool) (u *types.User, err error)
|
||||
validateEmailOTP func(context.Context, string) (err error)
|
||||
sendInviteEmail func(context.Context, string) (err error)
|
||||
validateEmailToken func(context.Context, string) (user *types.User, err error)
|
||||
}
|
||||
)
|
||||
|
||||
func (u userServiceMocked) UploadAvatar(ctx context.Context, userID uint64, upload *multipart.FileHeader) (err error) {
|
||||
return u.uploadAvatar(ctx, userID, upload)
|
||||
}
|
||||
|
||||
func (u userServiceMocked) GenerateAvatar(ctx context.Context, userID uint64, bgColor string, initialColor string) (err error) {
|
||||
return u.generateAvatar(ctx, userID, bgColor, initialColor)
|
||||
}
|
||||
|
||||
func (u userServiceMocked) DeleteAvatar(ctx context.Context, userID uint64) (err error) {
|
||||
return u.deleteAvatar(ctx, userID)
|
||||
}
|
||||
|
||||
// Mocking userService
|
||||
func (u userServiceMocked) Update(ctx context.Context, user *types.User) (*types.User, error) {
|
||||
return u.update(ctx, user)
|
||||
}
|
||||
|
||||
func (u userServiceMocked) FindByAny(ctx context.Context, any interface{}) (*types.User, error) {
|
||||
return u.findByAny(ctx, any)
|
||||
}
|
||||
|
||||
// Mocking authService
|
||||
func (s authServiceMocked) External(ctx context.Context, profile types.ExternalAuthUser) (u *types.User, err error) {
|
||||
return s.external(ctx, profile)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) InternalSignUp(ctx context.Context, input *types.User, password string) (u *types.User, err error) {
|
||||
return s.internalSignUp(ctx, input, password)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) InternalLogin(ctx context.Context, email string, password string) (u *types.User, err error) {
|
||||
return s.internalLogin(ctx, email, password)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) SetPassword(ctx context.Context, userID uint64, password string) (err error) {
|
||||
return s.setPassword(ctx, userID, password)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) ChangePassword(ctx context.Context, userID uint64, oldPassword, newPassword string) (err error) {
|
||||
return s.changePassword(ctx, userID, oldPassword, newPassword)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) ValidateEmailConfirmationToken(ctx context.Context, token string) (user *types.User, err error) {
|
||||
return s.validateEmailConfirmationToken(ctx, token)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) ValidatePasswordResetToken(ctx context.Context, token string) (user *types.User, err error) {
|
||||
return s.validatePasswordResetToken(ctx, token)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) ValidatePasswordCreateToken(ctx context.Context, token string) (user *types.User, err error) {
|
||||
return s.validatePasswordCreateToken(ctx, token)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) PasswordSet(ctx context.Context, email string) (is bool) {
|
||||
return s.passwordSet(ctx, email)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) SendEmailAddressConfirmationToken(ctx context.Context, u *types.User) (err error) {
|
||||
return s.sendEmailAddressConfirmationToken(ctx, u)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) SendPasswordResetToken(ctx context.Context, email string) (err error) {
|
||||
return s.sendPasswordResetToken(ctx, email)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) GetProviders() types.ExternalAuthProviderSet {
|
||||
return s.getProviders()
|
||||
}
|
||||
|
||||
func (s authServiceMocked) ValidateTOTP(ctx context.Context, code string) (err error) {
|
||||
return s.validateTOTP(ctx, code)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) ConfigureTOTP(ctx context.Context, secret string, code string) (u *types.User, err error) {
|
||||
return s.configureTOTP(ctx, secret, code)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) RemoveTOTP(ctx context.Context, userID uint64, code string) (u *types.User, err error) {
|
||||
return s.removeTOTP(ctx, userID, code)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) SendEmailOTP(ctx context.Context) (err error) {
|
||||
return s.sendEmailOTP(ctx)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) ConfigureEmailOTP(ctx context.Context, userID uint64, enable bool) (u *types.User, err error) {
|
||||
return s.configureEmailOTP(ctx, userID, enable)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) ValidateEmailOTP(ctx context.Context, code string) (err error) {
|
||||
return s.validateEmailOTP(ctx, code)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) SendInviteEmail(ctx context.Context, email string) (err error) {
|
||||
return s.sendInviteEmail(ctx, email)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) ValidateInviteEmailToken(ctx context.Context, token string) (user *types.User, err error) {
|
||||
return s.validateEmailToken(ctx, token)
|
||||
}
|
||||
|
||||
func (s authServiceMocked) LoadRoleMemberships(ctx context.Context, u *types.User) error {
|
||||
// no-op for now
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mocking oauth2Service
|
||||
func (s *oauth2ServiceMocked) GetRedirectURI(req *server.AuthorizeRequest, data map[string]interface{}) (string, error) {
|
||||
return s.GetRedirectURI(req, data)
|
||||
}
|
||||
|
||||
func (s *oauth2ServiceMocked) CheckResponseType(rt oauth2.ResponseType) bool {
|
||||
return s.checkResponseType(rt)
|
||||
}
|
||||
|
||||
func (s *oauth2ServiceMocked) CheckCodeChallengeMethod(ccm oauth2.CodeChallengeMethod) bool {
|
||||
return s.CheckCodeChallengeMethod(ccm)
|
||||
}
|
||||
|
||||
func (s *oauth2ServiceMocked) ValidationAuthorizeRequest(r *http.Request) (*server.AuthorizeRequest, error) {
|
||||
return s.validationAuthorizeRequest(r)
|
||||
}
|
||||
|
||||
func (s *oauth2ServiceMocked) GetAuthorizeToken(ctx context.Context, req *server.AuthorizeRequest) (oauth2.TokenInfo, error) {
|
||||
return s.getAuthorizeToken(ctx, req)
|
||||
}
|
||||
|
||||
func (s *oauth2ServiceMocked) GetAuthorizeData(rt oauth2.ResponseType, ti oauth2.TokenInfo) map[string]interface{} {
|
||||
return s.GetAuthorizeData(rt, ti)
|
||||
}
|
||||
|
||||
func (s *oauth2ServiceMocked) HandleAuthorizeRequest(w http.ResponseWriter, r *http.Request) error {
|
||||
return s.handleAuthorizeRequest(w, r)
|
||||
}
|
||||
|
||||
func (s *oauth2ServiceMocked) ValidationTokenRequest(r *http.Request) (oauth2.GrantType, *oauth2.TokenGenerateRequest, error) {
|
||||
return s.validationTokenRequest(r)
|
||||
}
|
||||
|
||||
func (s *oauth2ServiceMocked) CheckGrantType(gt oauth2.GrantType) bool {
|
||||
return s.checkGrantType(gt)
|
||||
}
|
||||
|
||||
func (s *oauth2ServiceMocked) GetAccessToken(ctx context.Context, gt oauth2.GrantType, tgr *oauth2.TokenGenerateRequest) (oauth2.TokenInfo, error) {
|
||||
return s.getAccessToken(ctx, gt, tgr)
|
||||
}
|
||||
|
||||
func (s *oauth2ServiceMocked) GetTokenData(ti oauth2.TokenInfo) map[string]interface{} {
|
||||
return s.getTokenData(ti)
|
||||
}
|
||||
|
||||
func (s *oauth2ServiceMocked) HandleTokenRequest(w http.ResponseWriter, r *http.Request) error {
|
||||
return s.handleTokenRequest(w, r)
|
||||
}
|
||||
|
||||
func (s *oauth2ServiceMocked) GetErrorData(err error) (map[string]interface{}, int, http.Header) {
|
||||
return s.getErrorData(err)
|
||||
}
|
||||
|
||||
func (s *oauth2ServiceMocked) BearerAuth(r *http.Request) (string, bool) {
|
||||
return s.bearerAuth(r)
|
||||
}
|
||||
|
||||
func (s *oauth2ServiceMocked) ValidationBearerToken(r *http.Request) (oauth2.TokenInfo, error) {
|
||||
return s.validationBearerToken(r)
|
||||
}
|
||||
|
||||
// Mocking authService
|
||||
func (ma mockAuthService) ValidatePasswordResetToken(ctx context.Context, token string) (*types.User, error) {
|
||||
return &types.User{ID: 123}, nil
|
||||
}
|
||||
|
||||
// Mocking authService
|
||||
func (ma mockAuthService) ValidatePasswordCreateToken(ctx context.Context, token string) (*types.User, error) {
|
||||
return &types.User{ID: 123}, nil
|
||||
}
|
||||
|
||||
func (ma mockAuthService) SendEmailOTP(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ma mockAuthService) ValidateTOTP(ctx context.Context, code string) (err error) {
|
||||
err = nil
|
||||
return
|
||||
}
|
||||
|
||||
func (ma mockAuthService) SendInviteEmail(ctx context.Context, email string) (err error) {
|
||||
err = nil
|
||||
return
|
||||
}
|
||||
|
||||
func (ma mockAuthService) ValidateInviteEmailToken(ctx context.Context, token string) (*types.User, error) {
|
||||
return &types.User{ID: 123}, nil
|
||||
}
|
||||
|
||||
// Mocking notification service
|
||||
func (m mockNotificationService) EmailConfirmation(ctx context.Context, emailAddress string, token string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m mockNotificationService) PasswordReset(ctx context.Context, emailAddress string, token string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m mockNotificationService) PasswordCreate(token string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (m mockNotificationService) EmailOTP(ctx context.Context, emailAddress string, code string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m mockNotificationService) InviteEmail(ctx context.Context, emailAddress string, token string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mocking gorilla session
|
||||
func (ms mockSession) Get(r *http.Request, name string) (*sessions.Session, error) {
|
||||
return ms.get(r, name)
|
||||
}
|
||||
func (ms mockSession) New(r *http.Request, name string) (*sessions.Session, error) {
|
||||
return ms.new(r, name)
|
||||
}
|
||||
func (ms mockSession) Save(r *http.Request, w http.ResponseWriter, s *sessions.Session) error {
|
||||
return ms.save(r, w, s)
|
||||
}
|
||||
|
||||
// Helpers
|
||||
func makeMockAuthService() *mockAuthService {
|
||||
service.DefaultAuthNotification = mockNotificationService{
|
||||
settings: service.CurrentSettings,
|
||||
opt: options.AuthOpt{},
|
||||
}
|
||||
|
||||
serviceAuth := service.Auth(service.AuthOptions{})
|
||||
|
||||
svc := mockAuthService{
|
||||
authService: serviceAuth,
|
||||
settings: service.CurrentSettings,
|
||||
providerValidator: func(s string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return &svc
|
||||
}
|
||||
|
||||
func makeMockUser() *types.User {
|
||||
u := &types.User{ID: 1, Username: "mock.user", Email: "mockuser@example.tld", Meta: &types.UserMeta{}}
|
||||
u.Meta.SecurityPolicy.MFA.EnforcedEmailOTP = true
|
||||
u.Meta.SecurityPolicy.MFA.EnforcedTOTP = false
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func prepareClientAuthReq(h *AuthHandlers, req *http.Request, user *types.User) *request.AuthReq {
|
||||
s := &settings.Settings{}
|
||||
s.MultiFactor.EmailOTP.Enabled = true
|
||||
s.MultiFactor.EmailOTP.Enforced = true
|
||||
s.MultiFactor.TOTP.Enabled = true
|
||||
|
||||
session := sessions.NewSession(&mockSession{
|
||||
save: func(r *http.Request, w http.ResponseWriter, s *sessions.Session) error {
|
||||
s.Values = make(map[interface{}]interface{})
|
||||
return nil
|
||||
},
|
||||
}, "session")
|
||||
|
||||
authReq := &request.AuthReq{
|
||||
Request: req,
|
||||
Locale: h.Locale,
|
||||
Session: session,
|
||||
Response: httptest.NewRecorder(),
|
||||
Data: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
if user != nil {
|
||||
authReq.AuthUser = request.NewAuthUser(s, user, true)
|
||||
}
|
||||
|
||||
return authReq
|
||||
}
|
||||
|
||||
func prepareClientAuthService() *mockAuthService {
|
||||
authService := makeMockAuthService()
|
||||
return authService
|
||||
}
|
||||
|
||||
func prepareClientAuthHandlers(authService authService, s *settings.Settings) *AuthHandlers {
|
||||
return &AuthHandlers{
|
||||
Log: zap.NewNop(),
|
||||
Locale: locale.Static(),
|
||||
AuthService: authService,
|
||||
Settings: s,
|
||||
}
|
||||
}
|
||||
148
server/auth/handlers/routes.go
Normal file
148
server/auth/handlers/routes.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/cortezaproject/corteza/server/pkg/actionlog"
|
||||
"github.com/cortezaproject/corteza/server/pkg/auth"
|
||||
"github.com/cortezaproject/corteza/server/pkg/locale"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/httprate"
|
||||
"github.com/gorilla/csrf"
|
||||
)
|
||||
|
||||
func (h *AuthHandlers) MountHttpRoutes(r chi.Router) {
|
||||
var (
|
||||
l = GetLinks()
|
||||
)
|
||||
|
||||
if h.Opt.DevelopmentMode {
|
||||
r.Get("/auth/dev", h.handle(h.devView))
|
||||
r.Get("/auth/dev/scenarios", h.devSceneView)
|
||||
}
|
||||
|
||||
r.Handle("/auth/", http.RedirectHandler("/auth", http.StatusSeeOther))
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(locale.DetectLanguage(locale.Global()))
|
||||
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := actionlog.RequestOriginToContext(r.Context(), actionlog.RequestOrigin_Auth)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
})
|
||||
|
||||
if h.Opt.RequestRateLimit > 0 {
|
||||
r.Use(httprate.LimitByIP(h.Opt.RequestRateLimit, h.Opt.RequestRateWindowLength))
|
||||
}
|
||||
|
||||
r.Use(auth.ExtraReqInfoMiddleware)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
// all routes protected with CSRF:
|
||||
if h.Opt.CsrfEnabled {
|
||||
r.Use(csrf.Protect(
|
||||
[]byte(h.Opt.CsrfSecret),
|
||||
csrf.SameSite(csrf.SameSiteStrictMode),
|
||||
csrf.Secure(h.Opt.SessionCookieSecure),
|
||||
csrf.FieldName(h.Opt.CsrfFieldName),
|
||||
csrf.CookieName(h.Opt.CsrfCookieName),
|
||||
))
|
||||
}
|
||||
|
||||
r.Get(tbp(l.Profile), h.handle(authOnly(h.profileForm)))
|
||||
r.Post(tbp(l.Profile), h.handle(authOnly(h.profileProc)))
|
||||
|
||||
r.HandleFunc(tbp(l.Logout), h.handle(h.logoutProc))
|
||||
|
||||
r.Get(tbp(l.Sessions), h.handle(authOnly(h.sessionsView)))
|
||||
r.Post(tbp(l.Sessions), h.handle(authOnly(h.sessionsProc)))
|
||||
|
||||
r.Get(tbp(l.AuthorizedClients), h.handle(authOnly(h.clientsView)))
|
||||
r.Post(tbp(l.AuthorizedClients), h.handle(authOnly(h.clientsProc)))
|
||||
|
||||
r.Get(tbp(l.Signup), h.handle(h.onlyIfSignupEnabled(anonyOnly(h.signupForm))))
|
||||
r.Post(tbp(l.Signup), h.handle(h.onlyIfSignupEnabled(anonyOnly(h.signupProc))))
|
||||
r.Get(tbp(l.PendingEmailConfirmation), h.handle(h.pendingEmailConfirmation))
|
||||
r.Get(tbp(l.ConfirmEmail), h.handle(h.confirmEmail))
|
||||
|
||||
r.Get(tbp(l.Login), h.handle(anonyOnly(h.loginForm)))
|
||||
r.Post(tbp(l.Login), h.handle(h.onlyIfLocalEnabled(anonyOnly(h.loginProc))))
|
||||
|
||||
r.Get(tbp(l.Mfa), h.handle(h.mfaForm))
|
||||
r.Post(tbp(l.Mfa), h.handle(h.mfaProc))
|
||||
|
||||
r.Get(tbp(l.RequestPasswordReset), h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.requestPasswordResetForm))))
|
||||
r.Post(tbp(l.RequestPasswordReset), h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.requestPasswordResetProc))))
|
||||
r.Get(tbp(l.PasswordResetRequested), h.handle(h.onlyIfPasswordResetEnabled(anonyOnly(h.passwordResetRequested))))
|
||||
r.Get(tbp(l.ResetPassword), h.handle(h.onlyIfPasswordResetEnabled(h.resetPasswordForm)))
|
||||
r.Post(tbp(l.ResetPassword), h.handle(h.onlyIfPasswordResetEnabled(authOnly(h.resetPasswordProc))))
|
||||
r.Get(tbp(l.AcceptInvite), h.handle(h.acceptInviteForm))
|
||||
r.Post(tbp(l.AcceptInvite), h.handle(h.acceptInviteProc))
|
||||
|
||||
r.Get(tbp(l.Security), h.handle(authOnly(h.securityForm)))
|
||||
r.Post(tbp(l.Security), h.handle(authOnly(h.securityProc)))
|
||||
r.Get(tbp(l.ChangePassword), h.handle(h.onlyIfLocalEnabled(authOnly(h.changePasswordForm))))
|
||||
r.Post(tbp(l.ChangePassword), h.handle(h.onlyIfLocalEnabled(authOnly(h.changePasswordProc))))
|
||||
r.Get(tbp(l.CreatePassword), h.handle(h.onlyIfPasswordCreateEnabled(h.createPasswordForm)))
|
||||
r.Post(tbp(l.CreatePassword), h.handle(h.onlyIfPasswordCreateEnabled(h.createPasswordProc)))
|
||||
|
||||
r.Get(tbp(l.MfaTotpNewSecret), h.handle(partAuthOnly(h.mfaTotpConfigForm)))
|
||||
r.Post(tbp(l.MfaTotpNewSecret), h.handle(partAuthOnly(h.mfaTotpConfigProc)))
|
||||
r.Get(tbp(l.MfaTotpQRImage), h.handle(partAuthOnly(h.mfaTotpConfigQR)))
|
||||
r.Get(tbp(l.MfaTotpDisable), h.handle(authOnly(h.mfaTotpDisableForm)))
|
||||
r.Post(tbp(l.MfaTotpDisable), h.handle(authOnly(h.mfaTotpDisableProc)))
|
||||
|
||||
})
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
// OAuth2 routes
|
||||
r.HandleFunc(tbp(l.OAuth2Authorize), h.handle(h.oauth2Authorize))
|
||||
r.Get(tbp(l.OAuth2AuthorizeClient), h.handle(authOnly(h.oauth2AuthorizeClient)))
|
||||
r.Post(tbp(l.OAuth2AuthorizeClient), h.handle(authOnly(h.oauth2AuthorizeClientProc)))
|
||||
r.Get(tbp(l.OAuth2DefaultClient), h.handle(h.oauth2authorizeDefaultClient))
|
||||
r.Post(tbp(l.OAuth2DefaultClient), h.handle(h.oauth2authorizeDefaultClientProc))
|
||||
})
|
||||
|
||||
// userinfo is authorized with an access token, not with a session; CSRF does not apply
|
||||
r.Get(tbp(l.OAuth2UserInfo), h.oauth2Userinfo)
|
||||
r.Post(tbp(l.OAuth2UserInfo), h.oauth2Userinfo)
|
||||
|
||||
// Wrapping SAML structs so we assure that fresh ones are always used in case
|
||||
// of settings changes.
|
||||
//
|
||||
// @todo refactor this with a wrapping struct to handle serve HTTPS and pass
|
||||
// calls to internal SAML service.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.HandleFunc(tbp(l.SamlMetadata), func(rw http.ResponseWriter, r *http.Request) {
|
||||
if h.SamlSPService == nil || !h.SamlSPService.Enabled {
|
||||
rw.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
h.SamlSPService.ServeHTTP(rw, r)
|
||||
})
|
||||
r.HandleFunc(tbp(l.SamlCallback), func(rw http.ResponseWriter, r *http.Request) {
|
||||
if h.SamlSPService == nil || !h.SamlSPService.Enabled {
|
||||
rw.WriteHeader(http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
h.SamlSPService.ServeHTTP(rw, r)
|
||||
})
|
||||
r.HandleFunc(tbp(l.SamlInit), func(rw http.ResponseWriter, r *http.Request) {
|
||||
h.samlInit(rw, r)
|
||||
})
|
||||
})
|
||||
|
||||
r.Route(tbp(l.External)+"/{provider}", func(r chi.Router) {
|
||||
// External provider
|
||||
r.Get("/", h.externalInit)
|
||||
r.Get("/callback", h.externalCallback)
|
||||
})
|
||||
|
||||
r.HandleFunc("/auth/oauth2/token", h.handle(h.oauth2Token))
|
||||
r.HandleFunc("/auth/oauth2/info", h.oauth2Info)
|
||||
r.HandleFunc("/auth/oauth2/public-keys", h.oauth2PublicKeys)
|
||||
})
|
||||
}
|
||||
66
server/auth/oauth2/client.go
Normal file
66
server/auth/oauth2/client.go
Normal 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()
|
||||
}
|
||||
}
|
||||
184
server/auth/oauth2/corteza_token_store.go
Normal file
184
server/auth/oauth2/corteza_token_store.go
Normal 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
|
||||
}
|
||||
135
server/auth/oauth2/oauth2.go
Normal file
135
server/auth/oauth2/oauth2.go
Normal 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
|
||||
}
|
||||
78
server/auth/oauth2/user_authorizer.go
Normal file
78
server/auth/oauth2/user_authorizer.go
Normal 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
|
||||
}
|
||||
169
server/auth/request/auth_user.go
Normal file
169
server/auth/request/auth_user.go
Normal 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)
|
||||
}
|
||||
}
|
||||
136
server/auth/request/context.go
Normal file
136
server/auth/request/context.go
Normal 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
|
||||
}
|
||||
244
server/auth/request/corteza_session_store.go
Normal file
244
server/auth/request/corteza_session_store.go
Normal 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{}
|
||||
60
server/auth/request/manager.go
Normal file
60
server/auth/request/manager.go
Normal 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)
|
||||
}
|
||||
109
server/auth/request/session.go
Normal file
109
server/auth/request/session.go
Normal 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
9
server/auth/saml/idp.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package saml
|
||||
|
||||
type (
|
||||
IdpIdentityPayload struct {
|
||||
Name string
|
||||
Handle string
|
||||
Identifier string
|
||||
}
|
||||
)
|
||||
57
server/auth/saml/saml.go
Normal file
57
server/auth/saml/saml.go
Normal 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
|
||||
}
|
||||
46
server/auth/saml/saml_test.go
Normal file
46
server/auth/saml/saml_test.go
Normal 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
161
server/auth/saml/sp.go
Normal 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)
|
||||
}
|
||||
71
server/auth/saml/sp_test.go
Normal file
71
server/auth/saml/sp_test.go
Normal 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))
|
||||
})
|
||||
}
|
||||
}
|
||||
104
server/auth/settings/settings.go
Normal file
104
server/auth/settings/settings.go
Normal 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
74
server/auth/templates.go
Normal 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
26
server/auth/token.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user