3 Commits

Author SHA1 Message Date
Priec
42bab82960 oauth2 2026-06-18 18:26:40 +02:00
Priec
7da4109584 RBAC via casbin
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-18 18:02:44 +02:00
Priec
ed607e3d27 login register 2026-06-18 17:19:04 +02:00
40 changed files with 2251 additions and 176 deletions

1064
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,10 @@ unic-langid = { version = "0.9" }
# /view engine # /view engine
axum-extra = { version = "0.10", features = ["form"] } axum-extra = { version = "0.10", features = ["form"] }
bytes = { version = "1" } bytes = { version = "1" }
axum-casbin = "1.3.0"
loco-oauth2 = "0.5.0"
passwords = "3.1.16"
tower-sessions = "0.14"
[[bin]] [[bin]]
name = "kompress-eshop-cli" name = "kompress-eshop-cli"

View File

@@ -57,12 +57,30 @@ album-by = by
album-play-full = Play full album album-play-full = Play full album
album-queue-all = queue all tracks in order album-queue-all = queue all tracks in order
album-no-tracks = no tracks yet album-no-tracks = no tracks yet
login-title = Admin login login-title = Sign in
login-error = Access denied - invalid email or password. login-error = Access denied - invalid email or password.
login-error-unverified = Your account isn't verified yet. Check your email and click the verification link.
login-root = root login-root = root
login-auth = Authenticate login-auth = Sign in
login-email = Email login-email = Email
login-password = Password login-password = Password
login-no-account = Don't have an account?
login-have-account = Already have an account?
auth-or = or
auth-google = Continue with Google
nav-login = Sign in
nav-register = Register
register-title = Create account
register-name = Name
register-submit = Create account
register-error-exists = An account with this email already exists.
register-error-invalid = Please check the details you entered and try again.
verify-sent-title = Check your email
verify-sent-body = We've sent a verification link to
verify-ok-title = Account verified
verify-ok-body = Your account is verified. You can now sign in.
verify-fail-title = Verification failed
verify-fail-body = This link is invalid or has expired.
auth = Auth auth = Auth
admin-session = Session admin-session = Session
readonly = readonly readonly = readonly

View File

@@ -57,12 +57,30 @@ album-by = od
album-play-full = Prehrať celý album album-play-full = Prehrať celý album
album-queue-all = zoradiť všetky skladby v poradí album-queue-all = zoradiť všetky skladby v poradí
album-no-tracks = zatiaľ žiadne skladby album-no-tracks = zatiaľ žiadne skladby
login-title = Prihlásenie admina login-title = Prihlásenie
login-error = Prístup odmietnutý - nesprávny e-mail alebo heslo. login-error = Prístup odmietnutý - nesprávny e-mail alebo heslo.
login-error-unverified = Účet ešte nie je overený. Skontrolujte si e-mail a kliknite na overovací odkaz.
login-root = root login-root = root
login-auth = Prihlásiť sa login-auth = Prihlásiť sa
login-email = E-mail login-email = E-mail
login-password = Heslo login-password = Heslo
login-no-account = Nemáte účet?
login-have-account = Už máte účet?
auth-or = alebo
auth-google = Pokračovať cez Google
nav-login = Prihlásiť sa
nav-register = Registrácia
register-title = Vytvoriť účet
register-name = Meno
register-submit = Zaregistrovať sa
register-error-exists = Účet s týmto e-mailom už existuje.
register-error-invalid = Skontrolujte zadané údaje a skúste to znova.
verify-sent-title = Skontrolujte si e-mail
verify-sent-body = Poslali sme overovací odkaz na adresu
verify-ok-title = Účet overený
verify-ok-body = Váš účet je overený. Teraz sa môžete prihlásiť.
verify-fail-title = Overenie zlyhalo
verify-fail-body = Tento odkaz je neplatný alebo mu vypršala platnosť.
auth = Overenie auth = Overenie
admin-session = Relácia admin-session = Relácia
readonly = iba na čítanie readonly = iba na čítanie

File diff suppressed because one or more lines are too long

View File

@@ -95,7 +95,7 @@
<a href="/" class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-info underline-offset-2 transition hover:bg-info/5 focus:outline-hidden focus-visible:underline"> <a href="/" class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-info underline-offset-2 transition hover:bg-info/5 focus:outline-hidden focus-visible:underline">
{{ t(key="admin-exit", lang=lang | default(value='sk')) }} {{ t(key="admin-exit", lang=lang | default(value='sk')) }}
</a> </a>
<form method="post" action="/admin/logout"> <form method="post" action="/logout">
<button type="submit" class="flex w-full items-center gap-2 rounded-radius px-2 py-1.5 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-danger/5 focus:outline-hidden focus-visible:underline"> <button type="submit" class="flex w-full items-center gap-2 rounded-radius px-2 py-1.5 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-danger/5 focus:outline-hidden focus-visible:underline">
{{ t(key="logout", lang=lang | default(value='sk')) }} {{ t(key="logout", lang=lang | default(value='sk')) }}
</button> </button>

View File

@@ -1,49 +0,0 @@
{% extends "base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ t(key="login-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %}
<div class="mx-auto mt-8 max-w-sm">
<div
class="rounded-radius border border-outline bg-surface-alt shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
<div
class="flex items-center justify-between border-b border-outline px-5 py-3 dark:border-outline-dark">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="nav-admin", lang=lang | default(value='sk')) }}
</span>
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="danger") }}
</div>
<div class="p-5">
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-auth", lang=lang | default(value='sk')) }}
</h1>
{% if error %}
{{ ui::alert_danger(message=t(key="login-error", lang=lang | default(value='sk')), extra="mt-3") }}
{% endif %}
<form method="post" action="/admin/login" hx-boost="false" class="mt-4 flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="email"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-email", lang=lang | default(value='sk')) }}
</label>
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email", attrs="autofocus") }}
</div>
<div class="flex flex-col gap-1">
<label for="password"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-password", lang=lang | default(value='sk')) }}
</label>
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="current-password") }}
</div>
{{ ui::button(label=t(key="login-auth", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
</form>
</div>
</div>
</div>
{% endblock content %}

View File

@@ -0,0 +1,64 @@
{% extends "base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ t(key="login-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %}
<div class="mx-auto mt-8 max-w-sm">
<div
class="rounded-radius border border-outline bg-surface-alt shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
<div
class="flex items-center justify-between border-b border-outline px-5 py-3 dark:border-outline-dark">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="brand", lang=lang | default(value='sk')) }}
</span>
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="primary") }}
</div>
<div class="p-5">
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-title", lang=lang | default(value='sk')) }}
</h1>
{% if error == "unverified" %}
{{ ui::alert_danger(message=t(key="login-error-unverified", lang=lang | default(value='sk')), extra="mt-3") }}
{% elif error %}
{{ ui::alert_danger(message=t(key="login-error", lang=lang | default(value='sk')), extra="mt-3") }}
{% endif %}
<form method="post" action="/login" hx-boost="false" class="mt-4 flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="email"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-email", lang=lang | default(value='sk')) }}
</label>
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email", attrs="autofocus") }}
</div>
<div class="flex flex-col gap-1">
<label for="password"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-password", lang=lang | default(value='sk')) }}
</label>
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="current-password") }}
</div>
{{ ui::button(label=t(key="login-auth", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
</form>
<div class="mt-5 flex items-center gap-3 text-xs text-on-surface/50 dark:text-on-surface-dark/50">
<span class="h-px flex-1 bg-outline dark:bg-outline-dark"></span>
{{ t(key="auth-or", lang=lang | default(value='sk')) }}
<span class="h-px flex-1 bg-outline dark:bg-outline-dark"></span>
</div>
{{ ui::button(label=t(key="auth-google", lang=lang | default(value='sk')), href="/api/oauth2/google", variant="outline-secondary", attrs='hx-boost="false"', extra="mt-4 w-full", icon='<svg class="size-4" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="#FFC107" d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"/><path fill="#FF3D00" d="m6.306 14.691 6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z"/><path fill="#4CAF50" d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z"/><path fill="#1976D2" d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z"/></svg>') }}
<p class="mt-4 text-sm text-on-surface dark:text-on-surface-dark">
{{ t(key="login-no-account", lang=lang | default(value='sk')) }}
<a href="/register"
class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="nav-register", lang=lang | default(value='sk')) }}</a>
</p>
</div>
</div>
</div>
{% endblock content %}

View File

@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ t(key="register-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %}
<div class="mx-auto mt-8 max-w-sm">
<div
class="rounded-radius border border-outline bg-surface-alt shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
<div
class="flex items-center justify-between border-b border-outline px-5 py-3 dark:border-outline-dark">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="brand", lang=lang | default(value='sk')) }}
</span>
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="primary") }}
</div>
<div class="p-5">
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="register-title", lang=lang | default(value='sk')) }}
</h1>
{% if error == "exists" %}
{{ ui::alert_danger(message=t(key="register-error-exists", lang=lang | default(value='sk')), extra="mt-3") }}
{% elif error %}
{{ ui::alert_danger(message=t(key="register-error-invalid", lang=lang | default(value='sk')), extra="mt-3") }}
{% endif %}
<form method="post" action="/register" hx-boost="false" class="mt-4 flex flex-col gap-4">
<div class="flex flex-col gap-1">
<label for="name"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="register-name", lang=lang | default(value='sk')) }}
</label>
{{ ui::input(name="name", id="name", required=true, autocomplete="name", attrs="autofocus") }}
</div>
<div class="flex flex-col gap-1">
<label for="email"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-email", lang=lang | default(value='sk')) }}
</label>
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email") }}
</div>
<div class="flex flex-col gap-1">
<label for="password"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-password", lang=lang | default(value='sk')) }}
</label>
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="new-password") }}
</div>
{{ ui::button(label=t(key="register-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
</form>
<div class="mt-5 flex items-center gap-3 text-xs text-on-surface/50 dark:text-on-surface-dark/50">
<span class="h-px flex-1 bg-outline dark:bg-outline-dark"></span>
{{ t(key="auth-or", lang=lang | default(value='sk')) }}
<span class="h-px flex-1 bg-outline dark:bg-outline-dark"></span>
</div>
{{ ui::button(label=t(key="auth-google", lang=lang | default(value='sk')), href="/api/oauth2/google", variant="outline-secondary", attrs='hx-boost="false"', extra="mt-4 w-full", icon='<svg class="size-4" viewBox="0 0 48 48" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path fill="#FFC107" d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 12.955 4 4 12.955 4 24s8.955 20 20 20 20-8.955 20-20c0-1.341-.138-2.65-.389-3.917z"/><path fill="#FF3D00" d="m6.306 14.691 6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4 16.318 4 9.656 8.337 6.306 14.691z"/><path fill="#4CAF50" d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.91 11.91 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44z"/><path fill="#1976D2" d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002 6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917z"/></svg>') }}
<p class="mt-4 text-sm text-on-surface dark:text-on-surface-dark">
{{ t(key="login-have-account", lang=lang | default(value='sk')) }}
<a href="/login"
class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a>
</p>
</div>
</div>
</div>
{% endblock content %}

View File

@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{% if ok %}{{ t(key="verify-ok-title", lang=lang | default(value='sk')) }}{% else %}{{ t(key="verify-fail-title", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
{% block content %}
<div class="mx-auto mt-8 max-w-sm">
<div
class="rounded-radius border border-outline bg-surface-alt p-5 shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
{% if ok %}
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="verify-ok-title", lang=lang | default(value='sk')) }}
</h1>
<p class="mt-3 text-sm text-on-surface dark:text-on-surface-dark">
{{ t(key="verify-ok-body", lang=lang | default(value='sk')) }}
</p>
{{ ui::button(label=t(key="login-auth", lang=lang | default(value='sk')), href="/login", extra="mt-4 w-full") }}
{% else %}
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="verify-fail-title", lang=lang | default(value='sk')) }}
</h1>
{{ ui::alert_danger(message=t(key="verify-fail-body", lang=lang | default(value='sk')), extra="mt-3") }}
{{ ui::button(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", variant="outline-primary", extra="mt-4 w-full") }}
{% endif %}
</div>
</div>
{% endblock content %}

View File

@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ t(key="verify-sent-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %}
<div class="mx-auto mt-8 max-w-sm">
<div
class="rounded-radius border border-outline bg-surface-alt p-5 shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="verify-sent-title", lang=lang | default(value='sk')) }}
</h1>
<p class="mt-3 text-sm text-on-surface dark:text-on-surface-dark">
{{ t(key="verify-sent-body", lang=lang | default(value='sk')) }}
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ email }}</span>
</p>
{{ ui::button(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", variant="outline-primary", extra="mt-4 w-full") }}
</div>
</div>
{% endblock content %}

View File

@@ -81,12 +81,13 @@
{% if logged_in_admin %} {% if logged_in_admin %}
<li>{{ ui::nav_link(label=t(key="admin-title", lang=lang | default(value='sk')), href="/admin/dashboard", data_nav="/admin", variant="warning", attrs='hx-boost="false"') }}</li> <li>{{ ui::nav_link(label=t(key="admin-title", lang=lang | default(value='sk')), href="/admin/dashboard", data_nav="/admin", variant="warning", attrs='hx-boost="false"') }}</li>
<li> <li>
<form method="post" action="/admin/logout" hx-boost="false"> <form method="post" action="/logout" hx-boost="false">
<button type="submit" class="text-sm font-medium text-danger underline-offset-2 transition hover:opacity-75 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button> <button type="submit" class="text-sm font-medium text-danger underline-offset-2 transition hover:opacity-75 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form> </form>
</li> </li>
{% else %} {% else %}
<li>{{ ui::nav_link(label=t(key="nav-admin", lang=lang | default(value='sk')), href="/admin/login", data_nav="/admin/login") }}</li> <li>{{ ui::nav_link(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", data_nav="/login") }}</li>
<li>{{ ui::nav_link(label=t(key="nav-register", lang=lang | default(value='sk')), href="/register", data_nav="/register") }}</li>
{% endif %} {% endif %}
</ul> </ul>
@@ -126,12 +127,13 @@
{% if logged_in_admin %} {% if logged_in_admin %}
<li><a href="/admin/dashboard" hx-boost="false" data-nav="/admin" class="block rounded-radius px-3 py-2 text-sm font-medium text-warning underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li> <li><a href="/admin/dashboard" hx-boost="false" data-nav="/admin" class="block rounded-radius px-3 py-2 text-sm font-medium text-warning underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
<li> <li>
<form method="post" action="/admin/logout" hx-boost="false"> <form method="post" action="/logout" hx-boost="false">
<button type="submit" class="block w-full rounded-radius px-3 py-2 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button> <button type="submit" class="block w-full rounded-radius px-3 py-2 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form> </form>
</li> </li>
{% else %} {% else %}
<li><a href="/admin/login" data-nav="/admin/login" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-admin", lang=lang | default(value='sk')) }}</a></li> <li><a href="/login" data-nav="/login" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/register" data-nav="/register" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-register", lang=lang | default(value='sk')) }}</a></li>
{% endif %} {% endif %}
</ul> </ul>
</nav> </nav>

24
config/casbin/model.conf Normal file
View File

@@ -0,0 +1,24 @@
# Casbin access model for the storefront.
#
# Request is (subject, object, action) = (role, request-path, HTTP-method);
# axum-casbin supplies path + method automatically and the subject comes from
# our JWT-derived CasbinVals (see src/shared/rbac.rs).
#
# Deny-override: every request is allowed unless a matching policy line marks it
# `deny`. That keeps the public storefront fully open and lets the policy file
# carve out the protected `/admin/*` subtree for non-admins only.
[request_definition]
r = sub, obj, act
[policy_definition]
p = sub, obj, act, eft
[role_definition]
g = _, _
[policy_effect]
e = !some(where (p.eft == deny))
[matchers]
m = (r.sub == p.sub || g(r.sub, p.sub)) && keyMatch(r.obj, p.obj) && regexMatch(r.act, p.act)

25
config/casbin/policy.csv Normal file
View File

@@ -0,0 +1,25 @@
# Authorization policy. Format: p, subject(role), object(path-pattern), action(method-regex), effect
#
# DECISION: this app intentionally runs with a SINGLE hardcoded admin (the user
# whose email matches ADMIN_EMAIL in .env, via guard::is_admin / admin_seeder).
# Everyone else is `customer` (logged in) or `anonymous` (not). There is no
# stored `role` column yet. This is a deliberate choice for current scale, not a
# limitation of the wiring — see src/shared/rbac.rs for the upgrade path.
#
# Deny everyone except admins under the admin subtree. `keyMatch` treats the
# trailing `*` as "anything after /admin/", so /admin/dashboard, /admin/orders/5
# etc. are all covered; the bare /admin entry point stays open so it can redirect
# to /login. Admins match no deny rule and so are allowed through.
#
# To grow this: give users a real role, emit it as the subject in
# src/shared/rbac.rs, then add `p` lines (allow/deny) and `g, <user>, <role>`
# mappings here.
p, customer, /admin/*, .*, deny
p, anonymous, /admin/*, .*, deny
# Admin-only endpoints that live outside the /admin/* subtree: the admin JSON
# API and the image upload. Public image serving (/images/{filename}) is GET and
# not matched here, so it stays open.
p, customer, /api/admin/*, .*, deny
p, anonymous, /api/admin/*, .*, deny
p, customer, /images/upload, .*, deny
p, anonymous, /images/upload, .*, deny
Can't render this file because it has a wrong number of fields in line 2.

View File

@@ -125,3 +125,30 @@ settings:
# Bank-transfer payment details shown on the order confirmation. # Bank-transfer payment details shown on the order confirmation.
bank_iban: {{ get_env(name="BANK_IBAN", default="SK00 0000 0000 0000 0000 0000") }} bank_iban: {{ get_env(name="BANK_IBAN", default="SK00 0000 0000 0000 0000 0000") }}
bank_account_name: {{ get_env(name="BANK_ACCOUNT_NAME", default="Kompress s.r.o.") }} bank_account_name: {{ get_env(name="BANK_ACCOUNT_NAME", default="Kompress s.r.o.") }}
# loco-oauth2: social login. Credentials come from .env (create them in the
# Google Cloud console and register the redirect_url below as an authorized
# redirect URI). Until OAUTH_CLIENT_ID/SECRET are set, the "Continue with
# Google" button will fail at the consent screen — the rest of auth is unaffected.
initializers:
oauth2:
# Key for the loco-oauth2 private cookie jar (>= 64 bytes). Override in prod.
secret_key: {{ get_env(name="OAUTH_PRIVATE_KEY", default="144, 76, 183, 1, 15, 184, 233, 174, 214, 251, 190, 186, 122, 61, 74, 84, 225, 110, 189, 115, 10, 251, 133, 128, 52, 46, 15, 66, 85, 1, 245, 73, 27, 113, 189, 15, 209, 205, 61, 100, 73, 31, 18, 58, 235, 105, 141, 36, 70, 92, 231, 151, 27, 32, 243, 117, 30, 244, 110, 89, 233, 196, 137, 130") }}
authorization_code:
- client_identifier: google
client_credentials:
client_id: {{ get_env(name="OAUTH_CLIENT_ID", default="oauth_client_id") }}
client_secret: {{ get_env(name="OAUTH_CLIENT_SECRET", default="oauth_client_secret") }}
url_config:
auth_url: {{ get_env(name="OAUTH_AUTH_URL", default="https://accounts.google.com/o/oauth2/auth") }}
token_url: {{ get_env(name="OAUTH_TOKEN_URL", default="https://www.googleapis.com/oauth2/v3/token") }}
redirect_url: {{ get_env(name="OAUTH_REDIRECT_URL", default="http://localhost:5150/api/oauth2/google/callback/cookie") }}
profile_url: {{ get_env(name="OAUTH_PROFILE_URL", default="https://openidconnect.googleapis.com/v1/userinfo") }}
scopes:
- "https://www.googleapis.com/auth/userinfo.email"
- "https://www.googleapis.com/auth/userinfo.profile"
cookie_config:
# After loco-oauth2 sets its session cookie it redirects here, where we
# mint our own auth_token cookie (see controllers/oauth2.rs::complete).
protected_url: {{ get_env(name="OAUTH_PROTECTED_URL", default="http://localhost:5150/api/oauth2/protected") }}
timeout_seconds: 600

View File

@@ -55,3 +55,25 @@ auth:
settings: settings:
admin_email: "{{ get_env(name="ADMIN_EMAIL", default="") }}" admin_email: "{{ get_env(name="ADMIN_EMAIL", default="") }}"
uploads_root: "{{ get_env(name="UPLOADS_ROOT", default="data/uploads") }}" uploads_root: "{{ get_env(name="UPLOADS_ROOT", default="data/uploads") }}"
# loco-oauth2 social login. All values must come from the environment in prod;
# OAUTH_REDIRECT_URL / OAUTH_PROTECTED_URL must use the real public origin.
initializers:
oauth2:
secret_key: "{{ get_env(name="OAUTH_PRIVATE_KEY") }}"
authorization_code:
- client_identifier: google
client_credentials:
client_id: "{{ get_env(name="OAUTH_CLIENT_ID") }}"
client_secret: "{{ get_env(name="OAUTH_CLIENT_SECRET") }}"
url_config:
auth_url: "{{ get_env(name="OAUTH_AUTH_URL", default="https://accounts.google.com/o/oauth2/auth") }}"
token_url: "{{ get_env(name="OAUTH_TOKEN_URL", default="https://www.googleapis.com/oauth2/v3/token") }}"
redirect_url: "{{ get_env(name="OAUTH_REDIRECT_URL") }}"
profile_url: "{{ get_env(name="OAUTH_PROFILE_URL", default="https://openidconnect.googleapis.com/v1/userinfo") }}"
scopes:
- "https://www.googleapis.com/auth/userinfo.email"
- "https://www.googleapis.com/auth/userinfo.profile"
cookie_config:
protected_url: "{{ get_env(name="OAUTH_PROTECTED_URL") }}"
timeout_seconds: 600

View File

@@ -0,0 +1,104 @@
# Google OAuth2 sign-in
"Continue with Google" on `/login` and `/register` is wired through
[`loco-oauth2`](https://github.com/yinho999/loco-oauth2). The code is complete
and compiles; this doc is the checklist to make the live flow work. Until the
credentials below are set, the button reaches Google and fails at the consent
screen — the rest of auth (password login, registration, verification) is
unaffected.
## How the flow works (for context)
1. User clicks **Continue with Google**`GET /api/oauth2/google` redirects to
Google's consent screen.
2. Google redirects back to `GET /api/oauth2/google/callback/cookie`.
loco-oauth2 exchanges the code, fetches the profile, upserts the user
(`OAuth2UserTrait::upsert_with_oauth`), stores an `o_auth2_sessions` row, sets
its own private session cookie, and redirects to `protected_url`.
3. `protected_url` = `GET /api/oauth2/protected` (our bridge,
`controllers/oauth2.rs::complete`). It mints **our** `auth_token` JWT cookie
and redirects: admins (email == `ADMIN_EMAIL`) → `/admin/dashboard`,
everyone else → `/`.
From there the user is a normal logged-in user (same JWT cookie as a password
login; the Casbin layer and guards treat them identically).
## 1. Create Google OAuth credentials
1. Go to <https://console.cloud.google.com/> → create/select a project.
2. **APIs & Services → OAuth consent screen**: configure it (External), add the
`.../auth/userinfo.email` and `.../auth/userinfo.profile` scopes, and add
your Google account as a **test user** while the app is in "Testing".
3. **APIs & Services → Credentials → Create Credentials → OAuth client ID**:
- Application type: **Web application**.
- **Authorized redirect URIs** — add exactly (must match the config's
`redirect_url`, no trailing slash):
- dev: `http://localhost:5150/api/oauth2/google/callback/cookie`
- prod: `https://YOUR_DOMAIN/api/oauth2/google/callback/cookie`
4. Copy the generated **Client ID** and **Client secret**.
## 2. Set environment variables (`.env`)
Read by `config/development.yaml``initializers.oauth2` (and the prod
equivalent). dotenvy loads `.env` on boot.
```bash
# Required
OAUTH_CLIENT_ID=xxxxxxxx.apps.googleusercontent.com
OAUTH_CLIENT_SECRET=xxxxxxxx
# Required in PRODUCTION (dev has working defaults)
OAUTH_PRIVATE_KEY="comma,separated,bytes >= 64 long" # key for loco-oauth2's private cookie jar
OAUTH_REDIRECT_URL=https://YOUR_DOMAIN/api/oauth2/google/callback/cookie
OAUTH_PROTECTED_URL=https://YOUR_DOMAIN/api/oauth2/protected
```
Notes:
- **dev** ships defaults for everything except `OAUTH_CLIENT_ID` /
`OAUTH_CLIENT_SECRET`, so locally you only need those two.
- `OAUTH_PRIVATE_KEY` must be ≥ 64 bytes (the dev default is a sample key — do
**not** reuse it in production). Generate a fresh one, e.g.
`python3 -c "import os;print(','.join(str(b) for b in os.urandom(64)))"`.
- `OAUTH_REDIRECT_URL` here and the Authorized redirect URI in the Google
console must be byte-for-byte identical.
## 3. Run / test
```bash
nix develop -c cargo loco start # MUST be inside nix develop (OpenSSL link, see memory)
```
- `auto_migrate: true` (dev) creates the `o_auth2_sessions` table on boot.
- Open `http://localhost:5150/login`**Continue with Google** → consent →
you should land back on `/` logged in (cart/nav reflect the session).
## 4. Production checklist
- [ ] Separate OAuth client (or at least the prod redirect URI) in Google.
- [ ] OAuth consent screen **published** (not just "Testing"), or real users
get blocked.
- [ ] `OAUTH_PRIVATE_KEY` set to a fresh ≥64-byte key (not the dev sample).
- [ ] `OAUTH_REDIRECT_URL` / `OAUTH_PROTECTED_URL` use the real `https://` origin.
- [ ] `server.host` / public origin correct so cookies + redirects resolve.
## Troubleshooting
| Symptom | Cause / fix |
|---|---|
| `redirect_uri_mismatch` at Google | Authorized redirect URI ≠ `OAUTH_REDIRECT_URL`. Make them identical (scheme, host, port, path, no trailing slash). |
| 403 / "access blocked: app not verified" | Add your account as a test user, or publish the consent screen. |
| `openssl-sys ... Could not find directory` at build | You ran `cargo` outside the dev shell. Use `nix develop -c cargo ...`. |
| Callback 500 / "could not create oauth2 store" | `initializers.oauth2` missing/invalid, or `OAUTH_PRIVATE_KEY` < 64 bytes. |
| Logged into Google but not into the app | The bridge (`/api/oauth2/protected`) didn't run check `protected_url` (`OAUTH_PROTECTED_URL`) points at it. |
## Where things live
- Config: `config/development.yaml` / `config/production.yaml`
`initializers.oauth2`
- Client store + session initializers: `src/initializers/oauth2.rs`,
`src/initializers/oauth2_session.rs`
- Routes + bridge handler: `src/controllers/oauth2.rs`
- User upsert (random password per advisory LOC-2025-04): `src/models/users.rs`
(`OAuth2UserTrait`)
- Session table: `src/models/o_auth2_sessions.rs` +
`migration/.../m20260618_000001_o_auth2_sessions.rs`

View File

@@ -86,7 +86,10 @@
buildInputs = [ buildInputs = [
rust rust
pkgs.pkg-config pkgs.pkg-config
# OpenSSL for crypto dependencies (loco-oauth2 -> oauth2/reqwest
# use native-tls); .dev provides headers + pkg-config metadata.
pkgs.openssl pkgs.openssl
pkgs.openssl.dev
pkgs.cmake pkgs.cmake
pkgs.llvmPackages.clang pkgs.llvmPackages.clang
pkgs.llvmPackages.libclang.lib pkgs.llvmPackages.libclang.lib

View File

@@ -30,6 +30,7 @@ mod m20260616_160000_add_parent_to_categories;
mod m20260617_000001_add_carrier_to_shipping_methods; mod m20260617_000001_add_carrier_to_shipping_methods;
mod m20260617_000002_add_shipment_to_orders; mod m20260617_000002_add_shipment_to_orders;
mod m20260617_000003_add_phone_to_orders; mod m20260617_000003_add_phone_to_orders;
mod m20260618_000001_o_auth2_sessions;
pub struct Migrator; pub struct Migrator;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -64,6 +65,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260617_000001_add_carrier_to_shipping_methods::Migration), Box::new(m20260617_000001_add_carrier_to_shipping_methods::Migration),
Box::new(m20260617_000002_add_shipment_to_orders::Migration), Box::new(m20260617_000002_add_shipment_to_orders::Migration),
Box::new(m20260617_000003_add_phone_to_orders::Migration), Box::new(m20260617_000003_add_phone_to_orders::Migration),
Box::new(m20260618_000001_o_auth2_sessions::Migration),
// inject-above (do not remove this comment) // inject-above (do not remove this comment)
] ]
} }

View File

@@ -0,0 +1,29 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
// OAuth2 session store used by loco-oauth2 to correlate the provider's
// access token with a local user during the callback flow. `user` adds
// a user_id FK to the users table.
create_table(
m,
"o_auth2_sessions",
&[
("id", ColType::PkAuto),
("session_id", ColType::StringUniq),
("expires_at", ColType::TimestampWithTimeZone),
],
&[("user", "")],
)
.await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "o_auth2_sessions").await
}
}

View File

@@ -17,8 +17,9 @@ use std::{path::Path, sync::Arc};
#[allow(unused_imports)] #[allow(unused_imports)]
use crate::{ use crate::{
controllers::{ controllers::{
admin_categories, admin_dashboard, admin_form, admin_login, admin_orders, admin_categories, admin_dashboard, admin_form, admin_orders,
admin_products, admin_shipping, auth, cart, checkout, home, i18n, media, shop, admin_products, admin_shipping, auth, auth_pages, cart, checkout, home, i18n, media, oauth2,
shop,
}, },
initializers, initializers,
models::_entities::users, models::_entities::users,
@@ -56,11 +57,27 @@ impl Hooks for App {
environment.load() environment.load()
} }
/// Attach the Casbin authorization layer on top of all routes. Order
/// matters: `inject_subject` is the outermost layer so it runs first and
/// stamps the JWT-derived role onto the request before the inner
/// `CasbinAxumLayer` enforces the policy. See `shared::rbac`.
async fn after_routes(router: axum::Router, ctx: &AppContext) -> Result<axum::Router> {
let casbin = crate::shared::rbac::layer().await?;
Ok(router
.layer(casbin)
.layer(axum::middleware::from_fn_with_state(
ctx.clone(),
crate::shared::rbac::inject_subject,
)))
}
async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> { async fn initializers(_ctx: &AppContext) -> Result<Vec<Box<dyn Initializer>>> {
Ok(vec![ Ok(vec![
Box::new(initializers::view_engine::ViewEngineInitializer), Box::new(initializers::view_engine::ViewEngineInitializer),
Box::new(initializers::admin_seeder::AdminSeeder), Box::new(initializers::admin_seeder::AdminSeeder),
Box::new(initializers::shipping_seeder::ShippingSeeder), Box::new(initializers::shipping_seeder::ShippingSeeder),
Box::new(initializers::oauth2::OAuth2StoreInitializer),
Box::new(initializers::oauth2_session::OAuth2SessionInitializer),
]) ])
} }
@@ -73,11 +90,12 @@ impl Hooks for App {
.add_route(checkout::routes()) .add_route(checkout::routes())
// cross-cutting // cross-cutting
.add_route(auth::routes()) .add_route(auth::routes())
.add_route(auth_pages::routes())
.add_route(oauth2::routes())
.add_route(i18n::routes()) .add_route(i18n::routes())
.add_route(media::routes()) .add_route(media::routes())
// admin // admin
.add_route(admin_dashboard::routes()) .add_route(admin_dashboard::routes())
.add_route(admin_login::routes())
.add_route(admin_products::routes()) .add_route(admin_products::routes())
.add_route(admin_categories::routes()) .add_route(admin_categories::routes())
.add_route(admin_orders::routes()) .add_route(admin_orders::routes())

View File

@@ -1,86 +0,0 @@
//! Cookie-based admin login/logout pages (separate from the JSON `/api/auth`
//! flow used by the SPA/API).
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use serde_json::json;
use crate::{
controllers::auth as auth_controller,
models::users::{self, LoginParams},
controllers::i18n::current_lang,
shared::guard,
};
fn login_error(v: &TeraView, jar: &CookieJar) -> Result<Response> {
format::view(
v,
"admin/login.html",
json!({
"error": "Invalid credentials",
"logged_in_admin": false,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn login_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
if guard::logged_in(&ctx, &jar).await {
return format::redirect("/admin/dashboard");
}
format::view(
&v,
"admin/login.html",
json!({
"error": null,
"logged_in_admin": false,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn login(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(params): Form<LoginParams>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
return login_error(&v, &jar);
};
if !user.verify_password(&params.password) || !guard::is_admin(&ctx, &user) {
return login_error(&v, &jar);
}
let jwt_secret = ctx.config.get_jwt_config()?;
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::render()
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
.redirect("/admin/dashboard")
}
#[debug_handler]
async fn logout() -> Result<Response> {
format::render()
.cookies(&[auth_controller::clear_auth_cookie()])?
.redirect("/admin/login")
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin", get(login_page))
.add("/admin/login", get(login_page))
.add("/admin/login", post(login))
.add("/admin/logout", post(logout))
}

View File

@@ -0,0 +1,216 @@
//! Cookie-based HTML auth pages (login, registration, email verification) for
//! all users. There is no role column — an "admin" is simply the user whose
//! email matches `settings.admin_email` (see [`guard::is_admin`]). On login,
//! admins are redirected to the admin dashboard and everyone else to the
//! storefront; both share the same `auth_token` cookie that the admin handlers
//! already validate. This is the unified replacement for the former
//! admin-only `/admin/login`. The JSON `/api/auth` flow in `auth.rs` is
//! separate and untouched.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use serde_json::json;
use crate::{
controllers::auth as auth_controller,
controllers::i18n::current_lang,
mailers::auth::AuthMailer,
models::users::{self, LoginParams, RegisterParams},
shared::guard,
};
/// Where a freshly-authenticated `user` should land.
fn home_for(ctx: &AppContext, user: &users::Model) -> &'static str {
if guard::is_admin(ctx, user) {
"/admin/dashboard"
} else {
"/"
}
}
fn login_view(v: &TeraView, jar: &CookieJar, error: Option<&str>) -> Result<Response> {
format::view(
v,
"auth/login.html",
json!({
"error": error,
"logged_in_admin": false,
"lang": current_lang(jar),
}),
)
}
fn register_view(v: &TeraView, jar: &CookieJar, error: Option<&str>) -> Result<Response> {
format::view(
v,
"auth/register.html",
json!({
"error": error,
"logged_in_admin": false,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn login_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
if let Some(user) = guard::current_user(&ctx, &jar).await {
return format::redirect(home_for(&ctx, &user));
}
login_view(&v, &jar, None)
}
#[debug_handler]
async fn login(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(params): Form<LoginParams>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_email(&ctx.db, &params.email).await else {
return login_view(&v, &jar, Some("invalid"));
};
if !user.verify_password(&params.password) {
return login_view(&v, &jar, Some("invalid"));
}
// Registration requires email verification before the account can sign in.
if user.email_verified_at.is_none() {
return login_view(&v, &jar, Some("unverified"));
}
let jwt_secret = ctx.config.get_jwt_config()?;
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
format::render()
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
.redirect(home_for(&ctx, &user))
}
#[debug_handler]
async fn register_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
if let Some(user) = guard::current_user(&ctx, &jar).await {
return format::redirect(home_for(&ctx, &user));
}
register_view(&v, &jar, None)
}
#[debug_handler]
async fn register(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(params): Form<RegisterParams>,
) -> Result<Response> {
let user = match users::Model::create_with_password(&ctx.db, &params).await {
Ok(user) => user,
Err(ModelError::EntityAlreadyExists {}) => {
return register_view(&v, &jar, Some("exists"));
}
Err(err) => {
// Most commonly a validation failure (name too short / invalid email).
tracing::info!(
message = err.to_string(),
user_email = &params.email,
"could not register user",
);
return register_view(&v, &jar, Some("invalid"));
}
};
let user = user
.into_active_model()
.set_email_verification_sent(&ctx.db)
.await?;
// The account already exists; a failed email send shouldn't 500 the page —
// log it and let the user fall back to resend-verification.
if let Err(err) = AuthMailer::send_welcome(&ctx, &user).await {
tracing::error!(
error = err.to_string(),
user_email = &user.email,
"failed to send verification email",
);
}
format::view(
&v,
"auth/verify_sent.html",
json!({
"email": user.email,
"logged_in_admin": false,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn verify(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Path(token): Path<String>,
) -> Result<Response> {
let Ok(user) = users::Model::find_by_verification_token(&ctx.db, &token).await else {
return verified_view(&v, &jar, false);
};
if user.email_verified_at.is_none() {
user.into_active_model().verified(&ctx.db).await?;
}
verified_view(&v, &jar, true)
}
fn verified_view(v: &TeraView, jar: &CookieJar, ok: bool) -> Result<Response> {
format::view(
v,
"auth/verified.html",
json!({
"ok": ok,
"logged_in_admin": false,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn logout() -> Result<Response> {
format::render()
.cookies(&[auth_controller::clear_auth_cookie()])?
.redirect("/login")
}
/// Backwards-compatible entry point: `/admin` sends admins to their dashboard
/// and everyone else to the unified login.
#[debug_handler]
async fn admin_entry(jar: CookieJar, State(ctx): State<AppContext>) -> Result<Response> {
if let Some(user) = guard::current_user(&ctx, &jar).await {
if guard::is_admin(&ctx, &user) {
return format::redirect("/admin/dashboard");
}
}
format::redirect("/login")
}
pub fn routes() -> Routes {
Routes::new()
.add("/login", get(login_page))
.add("/login", post(login))
.add("/register", get(register_page))
.add("/register", post(register))
.add("/verify/{token}", get(verify))
.add("/logout", post(logout))
.add("/admin", get(admin_entry))
}

View File

@@ -1,8 +1,9 @@
pub mod auth; pub mod auth;
pub mod auth_pages;
pub mod oauth2;
pub mod admin_categories; pub mod admin_categories;
pub mod admin_dashboard; pub mod admin_dashboard;
pub mod admin_form; pub mod admin_form;
pub mod admin_login;
pub mod admin_orders; pub mod admin_orders;
pub mod admin_products; pub mod admin_products;
pub mod admin_shipping; pub mod admin_shipping;

56
src/controllers/oauth2.rs Normal file
View File

@@ -0,0 +1,56 @@
//! HTML OAuth2 (Google) sign-in.
//!
//! The provider round-trip is handled by loco-oauth2's built-in authorize +
//! cookie-callback handlers. The callback upserts the user, stores an OAuth2
//! session, sets loco-oauth2's *private* session cookie, and redirects to the
//! configured `protected_url` — which is our [`complete`] handler. There we
//! trade the OAuth2 session for OUR Loco `auth_token` JWT cookie, so the rest of
//! the app (the Casbin layer, `guard`, the unified `/login`) treats a Google
//! user exactly like a password login. Admins (matching `ADMIN_EMAIL`) land on
//! the dashboard, everyone else on the storefront.
use loco_oauth2::controllers::{
middleware::OAuth2CookieUser,
oauth2::{google_authorization_url, google_callback_cookie},
};
use loco_rs::prelude::*;
use crate::{
controllers::auth as auth_controller,
models::{o_auth2_sessions, users, users::OAuth2UserProfile},
shared::guard,
};
type GoogleCookieUser = OAuth2CookieUser<OAuth2UserProfile, users::Model, o_auth2_sessions::Model>;
/// Bridge from loco-oauth2's session cookie to our own auth cookie.
#[debug_handler]
async fn complete(State(ctx): State<AppContext>, user: GoogleCookieUser) -> Result<Response> {
let user: &users::Model = user.as_ref();
let jwt_secret = ctx.config.get_jwt_config()?;
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
let dest = if guard::is_admin(&ctx, user) {
"/admin/dashboard"
} else {
"/"
};
format::render()
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
.redirect(dest)
}
pub fn routes() -> Routes {
Routes::new()
.prefix("api/oauth2")
// Redirects the browser to Google's consent screen.
.add("/google", get(google_authorization_url))
// Google redirects back here; loco-oauth2 exchanges the code, upserts
// the user, and redirects to `protected_url` (/api/oauth2/protected).
.add(
"/google/callback/cookie",
get(google_callback_cookie::<OAuth2UserProfile, users::Model, o_auth2_sessions::Model>),
)
.add("/protected", get(complete))
}

View File

@@ -1,4 +1,5 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::offset::Local;
use loco_rs::prelude::*; use loco_rs::prelude::*;
use loco_rs::hash; use loco_rs::hash;
use sea_orm::{ActiveModelTrait, IntoActiveModel, Set}; use sea_orm::{ActiveModelTrait, IntoActiveModel, Set};
@@ -30,10 +31,13 @@ impl Initializer for AdminSeeder {
let mut am = user.into_active_model(); let mut am = user.into_active_model();
am.password = Set(hash); am.password = Set(hash);
am.name = Set(name); am.name = Set(name);
// The admin signs in through the same /login flow as everyone else,
// which requires a verified email — keep the seeded admin verified.
am.email_verified_at = Set(Some(Local::now().into()));
am.update(&ctx.db).await?; am.update(&ctx.db).await?;
tracing::info!(admin = %email, "admin password synced from .env"); tracing::info!(admin = %email, "admin password synced from .env");
} else { } else {
users::Model::create_with_password( let user = users::Model::create_with_password(
&ctx.db, &ctx.db,
&RegisterParams { &RegisterParams {
email: email.clone(), email: email.clone(),
@@ -42,6 +46,10 @@ impl Initializer for AdminSeeder {
}, },
) )
.await?; .await?;
// Auto-verify so the seeded admin can log in without an email round-trip.
let mut am = user.into_active_model();
am.email_verified_at = Set(Some(Local::now().into()));
am.update(&ctx.db).await?;
tracing::info!(admin = %email, "admin user seeded"); tracing::info!(admin = %email, "admin user seeded");
} }

View File

@@ -1,3 +1,5 @@
pub mod admin_seeder; pub mod admin_seeder;
pub mod oauth2;
pub mod oauth2_session;
pub mod shipping_seeder; pub mod shipping_seeder;
pub mod view_engine; pub mod view_engine;

View File

@@ -0,0 +1,36 @@
//! Builds the loco-oauth2 client store from `initializers.oauth2` config and
//! injects it as an Axum extension so the oauth2 controllers can reach it.
use axum::{Extension, Router as AxumRouter};
use loco_oauth2::{config::Config as OAuth2Config, OAuth2ClientStore};
use loco_rs::prelude::*;
pub struct OAuth2StoreInitializer;
#[async_trait]
impl Initializer for OAuth2StoreInitializer {
fn name(&self) -> String {
"oauth2-store".to_string()
}
async fn after_routes(&self, router: AxumRouter, ctx: &AppContext) -> Result<AxumRouter> {
let settings = ctx.config.initializers.clone().ok_or_else(|| {
Error::Message("Initializers config not configured for OAuth2".to_string())
})?;
let oauth2_config_value = settings
.get("oauth2")
.ok_or_else(|| {
Error::Message("oauth2 config not found in initializers configuration".to_string())
})?
.clone();
let oauth2_config: OAuth2Config = oauth2_config_value.try_into().map_err(|e| {
tracing::error!(error = ?e, "could not convert oauth2 config from yaml");
Error::Message("could not convert oauth2 config from yaml".to_string())
})?;
let oauth2_store = OAuth2ClientStore::new(oauth2_config).map_err(|e| {
tracing::error!(error = ?e, "could not create oauth2 store from config");
Error::Message("could not create oauth2 store from config".to_string())
})?;
Ok(router.layer(Extension(oauth2_store)))
}
}

View File

@@ -0,0 +1,25 @@
//! tower-sessions layer that loco-oauth2 uses to hold the short-lived CSRF /
//! PKCE state between the authorize redirect and the provider callback. An
//! in-memory store is sufficient since the state only needs to survive the
//! round-trip to the provider.
use axum::Router as AxumRouter;
use loco_rs::prelude::*;
use tower_sessions::{cookie::time::Duration, Expiry, MemoryStore, SessionManagerLayer};
pub struct OAuth2SessionInitializer;
#[async_trait]
impl Initializer for OAuth2SessionInitializer {
fn name(&self) -> String {
"oauth2-session".to_string()
}
async fn after_routes(&self, router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
let session_store = MemoryStore::default();
let session_layer = SessionManagerLayer::new(session_store)
.with_secure(false)
.with_expiry(Expiry::OnInactivity(Duration::minutes(10)));
Ok(router.layer(session_layer))
}
}

View File

@@ -4,7 +4,7 @@
Dear {{name}}, Dear {{name}},
Welcome to Loco! You can now log in to your account. Welcome to Loco! You can now log in to your account.
Before you get started, please verify your account by clicking the link below: Before you get started, please verify your account by clicking the link below:
<a href="{{domain}}/api/auth/verify/{{verifyToken}}"> <a href="{{domain}}/verify/{{verifyToken}}">
Verify Your Account Verify Your Account
</a> </a>
<p>Best regards,<br>The Loco Team</p> <p>Best regards,<br>The Loco Team</p>

View File

@@ -1,4 +1,4 @@
Welcome {{name}}, you can now log in. Welcome {{name}}, you can now log in.
Verify your account with the link below: Verify your account with the link below:
{{domain}}/api/auth/verify/{{verifyToken}} {{domain}}/verify/{{verifyToken}}

View File

@@ -4,6 +4,7 @@ pub mod prelude;
pub mod audit_logs; pub mod audit_logs;
pub mod categories; pub mod categories;
pub mod o_auth2_sessions;
pub mod order_items; pub mod order_items;
pub mod orders; pub mod orders;
pub mod product_images; pub mod product_images;

View File

@@ -0,0 +1,36 @@
//! `SeaORM` Entity for loco-oauth2 sessions. Hand-written to match the
//! `o_auth2_sessions` migration (the rest of `_entities/` is codegen; this table
//! is owned by the loco-oauth2 integration).
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "o_auth2_sessions")]
pub struct Model {
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
#[sea_orm(primary_key)]
pub id: i32,
pub session_id: String,
pub expires_at: DateTimeUtc,
pub user_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UserId",
to = "super::users::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}

View File

@@ -2,6 +2,7 @@
pub use super::audit_logs::Entity as AuditLogs; pub use super::audit_logs::Entity as AuditLogs;
pub use super::categories::Entity as Categories; pub use super::categories::Entity as Categories;
pub use super::o_auth2_sessions::Entity as OAuth2Sessions;
pub use super::order_items::Entity as OrderItems; pub use super::order_items::Entity as OrderItems;
pub use super::orders::Entity as Orders; pub use super::orders::Entity as Orders;
pub use super::product_images::Entity as ProductImages; pub use super::product_images::Entity as ProductImages;

View File

@@ -8,6 +8,7 @@ pub mod _entities;
pub mod audit_logs; pub mod audit_logs;
pub mod categories; pub mod categories;
pub mod o_auth2_sessions;
pub mod order_items; pub mod order_items;
pub mod orders; pub mod orders;
pub mod product_images; pub mod product_images;

View File

@@ -0,0 +1,79 @@
pub use super::_entities::o_auth2_sessions::{ActiveModel, Column, Entity, Model};
use crate::models::{o_auth2_sessions, users};
use async_trait::async_trait;
use chrono::Utc;
use loco_oauth2::base_oauth2::{basic::BasicTokenResponse, TokenResponse};
use loco_oauth2::models::oauth2_sessions::OAuth2SessionsTrait;
use loco_rs::prelude::*;
use sea_orm::entity::prelude::*;
pub type OAuth2Sessions = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
where
C: ConnectionTrait,
{
if !insert && self.updated_at.is_unchanged() {
let mut this = self;
this.updated_at = sea_orm::ActiveValue::Set(Utc::now());
Ok(this)
} else {
Ok(self)
}
}
}
#[async_trait]
impl OAuth2SessionsTrait<users::Model> for Model {
/// Whether the session identified by `session_id` has expired.
async fn is_expired(db: &DatabaseConnection, session_id: &str) -> ModelResult<bool> {
let session = o_auth2_sessions::Entity::find()
.filter(o_auth2_sessions::Column::SessionId.eq(session_id))
.one(db)
.await?
.ok_or_else(|| ModelError::EntityNotFound)?;
Ok(session.expires_at < Utc::now())
}
/// Create or refresh the session row for `user` from the provider token.
async fn upsert_with_oauth2(
db: &DatabaseConnection,
token: &BasicTokenResponse,
user: &users::Model,
) -> ModelResult<Self> {
let txn = db.begin().await?;
let session_id = token.access_token().secret().clone();
let expires_at = Utc::now()
+ token
.expires_in()
.unwrap_or(std::time::Duration::from_secs(3600));
let session = match o_auth2_sessions::Entity::find()
.filter(o_auth2_sessions::Column::UserId.eq(user.id))
.one(&txn)
.await?
{
Some(session) => {
let mut session: o_auth2_sessions::ActiveModel = session.into();
session.session_id = ActiveValue::set(session_id);
session.expires_at = ActiveValue::set(expires_at);
session.updated_at = ActiveValue::set(Utc::now());
session.update(&txn).await?
}
None => {
o_auth2_sessions::ActiveModel {
session_id: ActiveValue::set(session_id),
expires_at: ActiveValue::set(expires_at),
user_id: ActiveValue::set(user.id),
..Default::default()
}
.insert(&txn)
.await?
}
};
txn.commit().await?;
Ok(session)
}
}

View File

@@ -1,10 +1,13 @@
use async_trait::async_trait; use async_trait::async_trait;
use chrono::{offset::Local, Duration}; use chrono::{offset::Local, Duration};
use loco_oauth2::models::users::OAuth2UserTrait;
use loco_rs::{auth::jwt, hash, prelude::*}; use loco_rs::{auth::jwt, hash, prelude::*};
use passwords::PasswordGenerator;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Map; use serde_json::Map;
use uuid::Uuid; use uuid::Uuid;
use crate::models::_entities::o_auth2_sessions;
pub use crate::models::_entities::users::{self, ActiveModel, Entity, Model}; pub use crate::models::_entities::users::{self, ActiveModel, Entity, Model};
pub const MAGIC_LINK_LENGTH: i8 = 32; pub const MAGIC_LINK_LENGTH: i8 = 32;
@@ -367,3 +370,93 @@ impl ActiveModel {
self.update(db).await.map_err(ModelError::from) self.update(db).await.map_err(ModelError::from)
} }
} }
/// Google OpenID Connect user profile (the fields our scopes request).
/// <https://developers.google.com/identity/openid-connect/openid-connect#obtainuserinfo>
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct OAuth2UserProfile {
pub email: String,
pub name: String,
pub sub: String,
pub email_verified: bool,
pub given_name: Option<String>,
pub family_name: Option<String>,
pub picture: Option<String>,
pub locale: Option<String>,
}
#[async_trait]
impl OAuth2UserTrait<OAuth2UserProfile> for Model {
/// Resolve the user behind an active OAuth2 session id.
async fn find_by_oauth2_session_id(
db: &DatabaseConnection,
session_id: &str,
) -> ModelResult<Self> {
let session = o_auth2_sessions::Entity::find()
.filter(o_auth2_sessions::Column::SessionId.eq(session_id))
.one(db)
.await?
.ok_or_else(|| ModelError::EntityNotFound)?;
users::Entity::find_by_id(session.user_id)
.one(db)
.await?
.ok_or_else(|| ModelError::EntityNotFound)
}
/// Find-or-create the local user for a verified OAuth2 profile.
///
/// Per security advisory LOC-2025-04, OAuth2-created accounts get a strong
/// RANDOM password (never the provider `sub`) — they sign in via the
/// provider, and the random secret just satisfies the NOT NULL column.
/// Google has already verified the email, so we mark it verified.
async fn upsert_with_oauth(
db: &DatabaseConnection,
profile: &OAuth2UserProfile,
) -> ModelResult<Self> {
let txn = db.begin().await?;
let user = match users::Entity::find()
.filter(users::Column::Email.eq(&profile.email))
.one(&txn)
.await?
{
Some(user) => user,
None => {
let password = PasswordGenerator::new()
.length(16)
.numbers(true)
.lowercase_letters(true)
.uppercase_letters(true)
.symbols(true)
.exclude_similar_characters(true)
.strict(true)
.generate_one()
.map_err(|e| ModelError::Any(e.into()))?;
let password_hash =
hash::hash_password(&password).map_err(|e| ModelError::Any(e.into()))?;
users::ActiveModel {
email: ActiveValue::set(profile.email.to_string()),
name: ActiveValue::set(profile.name.to_string()),
email_verified_at: ActiveValue::set(Some(Local::now().into())),
password: ActiveValue::set(password_hash),
..Default::default()
}
.insert(&txn)
.await
.map_err(|e| {
tracing::error!(error = %e, "failed to create OAuth2 user");
ModelError::Any(e.into())
})?
}
};
txn.commit().await?;
Ok(user)
}
/// Required by the trait; mirrors the inherent [`Model::generate_jwt`]
/// (inlined to avoid the inherent/trait name clash).
fn generate_jwt(&self, secret: &str, expiration: &u64) -> ModelResult<String> {
jwt::JWT::new(secret)
.generate_token(*expiration, self.pid.to_string(), Map::new())
.map_err(ModelError::from)
}
}

View File

@@ -23,21 +23,25 @@ pub async fn current_admin(auth: auth::JWT, ctx: &AppContext) -> Result<users::M
Ok(user) Ok(user)
} }
/// Soft auth for public pages: returns the user behind a valid auth cookie, or
/// `None`. Never errors — used to decide chrome and post-login redirects, not
/// to gate protected handlers (use [`current_admin`] for that).
pub async fn current_user(ctx: &AppContext, jar: &CookieJar) -> Option<users::Model> {
let cookie = jar.get(AUTH_COOKIE)?;
let jwt_config = ctx.config.get_jwt_config().ok()?;
let claims = loco_rs::auth::jwt::JWT::new(&jwt_config.secret)
.validate(cookie.value())
.ok()?;
users::Model::find_by_pid(&ctx.db, &claims.claims.pid)
.await
.ok()
}
/// Soft check for public pages: does the request carry a valid admin auth /// Soft check for public pages: does the request carry a valid admin auth
/// cookie? Never errors — used only to decide whether to show admin chrome. /// cookie? Never errors — used only to decide whether to show admin chrome.
pub async fn logged_in(ctx: &AppContext, jar: &CookieJar) -> bool { pub async fn logged_in(ctx: &AppContext, jar: &CookieJar) -> bool {
let Some(cookie) = jar.get(AUTH_COOKIE) else { match current_user(ctx, jar).await {
return false; Some(user) => is_admin(ctx, &user),
}; None => false,
let Ok(jwt_config) = ctx.config.get_jwt_config() else { }
return false;
};
let Ok(claims) = loco_rs::auth::jwt::JWT::new(&jwt_config.secret).validate(cookie.value())
else {
return false;
};
let Ok(user) = users::Model::find_by_pid(&ctx.db, &claims.claims.pid).await else {
return false;
};
is_admin(ctx, &user)
} }

View File

@@ -2,5 +2,6 @@
pub mod guard; pub mod guard;
pub mod money; pub mod money;
pub mod rbac;
pub mod settings; pub mod settings;
pub mod slug; pub mod slug;

129
src/shared/rbac.rs Normal file
View File

@@ -0,0 +1,129 @@
//! Casbin-based authorization layer.
//!
//! Authentication stays with Loco's JWT cookie (see [`crate::shared::guard`]);
//! Casbin only answers "is this subject allowed on this path?". At request time
//! [`inject_subject`] resolves the caller's role from the JWT and stamps it onto
//! the request as `CasbinVals`, then [`CasbinAxumLayer`] enforces the policy in
//! `config/casbin/{model.conf,policy.csv}`.
//!
//! The model is deny-override: everything is allowed by default and the policy
//! only *denies* non-admins under `/admin/*`. The per-handler
//! [`guard::current_admin`] checks stay in place as defense in depth.
//!
//! DECISION — single hardcoded admin: there is intentionally no stored `role`.
//! The one admin is the user whose email matches `ADMIN_EMAIL` in `.env`
//! (granted in `admin_seeder`, detected by [`guard::is_admin`]); every other
//! authenticated user is `customer` and the rest are `anonymous`. This is a
//! deliberate fit for the current scale, not a constraint of the design.
//!
//! Upgrade path when more roles are needed (each step is localized): add a
//! `role` column to `users` (migration), set it on register/seed, read
//! `user.role` in [`inject_subject`] instead of the `is_admin` check below, and
//! add `p`/`g` lines to `config/casbin/policy.csv`. The enforcement layer,
//! `after_routes` wiring, and tests are unaffected by that change.
use axum::{
extract::{Request, State},
middleware::Next,
response::Response,
};
use axum_casbin::{
casbin::{DefaultModel, FileAdapter},
CasbinAxumLayer, CasbinVals,
};
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use crate::shared::guard;
const MODEL_PATH: &str = "config/casbin/model.conf";
const POLICY_PATH: &str = "config/casbin/policy.csv";
/// Build the Casbin enforcement layer from the on-disk model + policy.
pub async fn layer() -> Result<CasbinAxumLayer> {
let model = DefaultModel::from_file(MODEL_PATH)
.await
.map_err(|e| Error::Message(format!("casbin model load failed: {e}")))?;
let adapter = FileAdapter::new(POLICY_PATH);
CasbinAxumLayer::new(model, adapter)
.await
.map_err(|e| Error::Message(format!("casbin enforcer init failed: {e}")))
}
/// Resolve the caller's role from the Loco JWT cookie and attach it as the
/// Casbin subject. Always sets *some* subject (anonymous requests included) so
/// the enforcer never sees an empty value.
pub async fn inject_subject(
State(ctx): State<AppContext>,
jar: CookieJar,
mut req: Request,
next: Next,
) -> Response {
let subject = match guard::current_user(&ctx, &jar).await {
Some(user) if guard::is_admin(&ctx, &user) => "admin",
Some(_) => "customer",
None => "anonymous",
};
req.extensions_mut().insert(CasbinVals {
subject: subject.to_string(),
domain: None,
});
next.run(req).await
}
#[cfg(test)]
mod tests {
use axum_casbin::casbin::{CoreApi, DefaultModel, Enforcer, FileAdapter};
async fn enforcer() -> Enforcer {
let model = DefaultModel::from_file(super::MODEL_PATH).await.unwrap();
let adapter = FileAdapter::new(super::POLICY_PATH);
Enforcer::new(model, adapter).await.unwrap()
}
async fn allowed(e: &Enforcer, sub: &str, obj: &str, act: &str) -> bool {
e.enforce((sub, obj, act)).unwrap()
}
#[tokio::test]
async fn admin_subtree_is_admin_only() {
let e = enforcer().await;
// Admins reach the protected subtree (any method, nested paths).
assert!(allowed(&e, "admin", "/admin/dashboard", "GET").await);
assert!(allowed(&e, "admin", "/admin/orders/5", "POST").await);
// Customers and anonymous are denied there.
assert!(!allowed(&e, "customer", "/admin/dashboard", "GET").await);
assert!(!allowed(&e, "customer", "/admin/orders/5", "POST").await);
assert!(!allowed(&e, "anonymous", "/admin/products", "GET").await);
}
#[tokio::test]
async fn admin_only_endpoints_outside_admin_subtree() {
let e = enforcer().await;
// Admin JSON API and image upload are admin-only.
assert!(allowed(&e, "admin", "/api/admin/dashboard", "GET").await);
assert!(allowed(&e, "admin", "/images/upload", "POST").await);
assert!(!allowed(&e, "customer", "/api/admin/dashboard", "GET").await);
assert!(!allowed(&e, "anonymous", "/images/upload", "POST").await);
// Public image serving stays open for everyone.
assert!(allowed(&e, "anonymous", "/images/logo.png", "GET").await);
assert!(allowed(&e, "customer", "/images/logo.png", "GET").await);
}
#[tokio::test]
async fn storefront_is_open_to_everyone() {
let e = enforcer().await;
for sub in ["admin", "customer", "anonymous"] {
assert!(allowed(&e, sub, "/", "GET").await);
assert!(allowed(&e, sub, "/shop", "GET").await);
assert!(allowed(&e, sub, "/login", "POST").await);
// The bare /admin entry stays open so it can redirect to /login.
assert!(allowed(&e, sub, "/admin", "GET").await);
}
}
}