mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-05-24 19:35:28 +00:00
properly save session with multiple auth
This will help knowing when the user last authed, support MFA & other login types, support acr_values & max_time, etc.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
-- Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
--
|
||||
-- Licensed under the Apache License, Version 2.0 (the "License");
|
||||
-- you may not use this file except in compliance with the License.
|
||||
-- You may obtain a copy of the License at
|
||||
--
|
||||
-- http://www.apache.org/licenses/LICENSE-2.0
|
||||
--
|
||||
-- Unless required by applicable law or agreed to in writing, software
|
||||
-- distributed under the License is distributed on an "AS IS" BASIS,
|
||||
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
-- See the License for the specific language governing permissions and
|
||||
-- limitations under the License.
|
||||
|
||||
DROP TRIGGER set_timestamp ON user_sessions;
|
||||
DROP TABLE user_session_authentications;
|
||||
DROP TABLE user_sessions;
|
||||
@@ -0,0 +1,35 @@
|
||||
-- Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
--
|
||||
-- Licensed under the Apache License, Version 2.0 (the "License");
|
||||
-- you may not use this file except in compliance with the License.
|
||||
-- You may obtain a copy of the License at
|
||||
--
|
||||
-- http://www.apache.org/licenses/LICENSE-2.0
|
||||
--
|
||||
-- Unless required by applicable law or agreed to in writing, software
|
||||
-- distributed under the License is distributed on an "AS IS" BASIS,
|
||||
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
-- See the License for the specific language governing permissions and
|
||||
-- limitations under the License.
|
||||
|
||||
-- A logged in session
|
||||
CREATE TABLE user_sessions (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"user_id" INT NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||
"active" BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TRIGGER set_timestamp
|
||||
BEFORE UPDATE ON user_sessions
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE trigger_set_timestamp();
|
||||
|
||||
-- An authentication within a session
|
||||
CREATE TABLE user_session_authentications (
|
||||
"id" SERIAL PRIMARY KEY,
|
||||
"session_id" INT NOT NULL REFERENCES user_sessions (id) ON DELETE CASCADE,
|
||||
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -20,6 +20,7 @@ use tide::{
|
||||
security::{CorsMiddleware, Origin},
|
||||
Middleware, Redirect, Server,
|
||||
};
|
||||
use tracing::error;
|
||||
use url::Url;
|
||||
|
||||
use crate::{
|
||||
@@ -67,8 +68,8 @@ async fn redirect_uri_from_params<T>(
|
||||
None
|
||||
};
|
||||
|
||||
let redirect_uri = client.resolve_redirect_uri(redirect_uri)?;
|
||||
Ok(redirect_uri)
|
||||
let redirect_uri = client.resolve_redirect_uri(&redirect_uri)?;
|
||||
Ok(redirect_uri.clone())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -83,6 +84,7 @@ impl Middleware<State> for BrowserErrorHandler {
|
||||
let redirect_uri = redirect_uri_from_params(params, storage).await;
|
||||
let mut response = next.run(request).await;
|
||||
if let Some(err) = response.take_error() {
|
||||
error!("{}", err);
|
||||
if let Ok(mut redirect_uri) = redirect_uri {
|
||||
redirect_uri
|
||||
.query_pairs_mut()
|
||||
@@ -128,11 +130,19 @@ pub fn install(app: &mut Server<State>) {
|
||||
views.with(state.session_middleware());
|
||||
views.with(state.csrf_middleware());
|
||||
views.with(crate::middlewares::errors);
|
||||
|
||||
views.at("/").get(self::views::index::get);
|
||||
|
||||
views
|
||||
.at("/login")
|
||||
.get(self::views::login::get)
|
||||
.post(self::views::login::post);
|
||||
|
||||
views
|
||||
.at("/reauth")
|
||||
.get(self::views::reauth::get)
|
||||
.post(self::views::reauth::post);
|
||||
|
||||
views.at("/logout").post(self::views::logout::post);
|
||||
|
||||
views
|
||||
|
||||
@@ -27,6 +27,7 @@ pub async fn get(req: Request<State>) -> tide::Result {
|
||||
let state = req.state();
|
||||
let ctx = common_context(&req).await?;
|
||||
|
||||
// TODO: check if there is an existing session
|
||||
let content = state.templates().render("login.html", &ctx)?;
|
||||
let body = Response::builder(200)
|
||||
.body(content)
|
||||
@@ -40,13 +41,13 @@ pub async fn post(mut req: Request<State>) -> tide::Result {
|
||||
let form = form.verify_csrf(&req)?;
|
||||
let state = req.state();
|
||||
|
||||
let user = state
|
||||
let session_info = state
|
||||
.storage()
|
||||
.login(&form.username, &form.password)
|
||||
.await?;
|
||||
|
||||
let session = req.session_mut();
|
||||
session.insert("current_user", user.key())?;
|
||||
session.insert("current_session", session_info.key())?;
|
||||
|
||||
Ok(Redirect::new("/").into())
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ pub async fn post(mut req: Request<State>) -> tide::Result {
|
||||
form.verify_csrf(&req)?;
|
||||
|
||||
let session = req.session_mut();
|
||||
session.remove("current_user");
|
||||
session.remove("current_session");
|
||||
|
||||
Ok(Redirect::new("/").into())
|
||||
}
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
pub(super) mod index;
|
||||
pub(super) mod login;
|
||||
pub(super) mod logout;
|
||||
pub(super) mod reauth;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use serde::Deserialize;
|
||||
use tide::{Redirect, Request, Response};
|
||||
|
||||
use crate::{csrf::CsrfForm, state::State, templates::common_context};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReauthForm {
|
||||
password: String,
|
||||
}
|
||||
|
||||
pub async fn get(req: Request<State>) -> tide::Result {
|
||||
let state = req.state();
|
||||
let ctx = common_context(&req).await?;
|
||||
|
||||
// TODO: check if there is an existing session
|
||||
let content = state.templates().render("reauth.html", &ctx)?;
|
||||
let body = Response::builder(200)
|
||||
.body(content)
|
||||
.content_type("text/html")
|
||||
.into();
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
pub async fn post(mut req: Request<State>) -> tide::Result {
|
||||
let form: CsrfForm<ReauthForm> = req.body_form().await?;
|
||||
let form = form.verify_csrf(&req)?;
|
||||
let state = req.state();
|
||||
let session = req.session();
|
||||
|
||||
let session_id = session
|
||||
.get("current_session")
|
||||
.ok_or_else(|| anyhow::anyhow!("could not find existing session"))?;
|
||||
|
||||
let _session = state
|
||||
.storage()
|
||||
.lookup_and_reauth_session(session_id, &form.password)
|
||||
.await?;
|
||||
|
||||
Ok(Redirect::new("/").into())
|
||||
}
|
||||
@@ -153,7 +153,7 @@ pub fn middleware<'a>(
|
||||
// way with a backtrace if we have one
|
||||
let details = response.take_error().map(|err| {
|
||||
format!(
|
||||
"{}{}",
|
||||
"{:?}{}",
|
||||
err,
|
||||
err.backtrace()
|
||||
.map(|bt| format!("\nBacktrace:\n{}", bt.to_string()))
|
||||
|
||||
@@ -14,14 +14,15 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
use crate::config::OAuth2ClientConfig;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Client {
|
||||
client_id: String,
|
||||
pub client_id: String,
|
||||
redirect_uris: Option<HashSet<Url>>,
|
||||
}
|
||||
|
||||
@@ -34,17 +35,15 @@ pub struct ClientLookupError;
|
||||
pub struct InvalidRedirectUriError;
|
||||
|
||||
impl Client {
|
||||
pub fn resolve_redirect_uri(
|
||||
&self,
|
||||
suggested_uri: Option<Url>,
|
||||
) -> Result<Url, InvalidRedirectUriError> {
|
||||
pub fn resolve_redirect_uri<'a>(
|
||||
&'a self,
|
||||
suggested_uri: &'a Option<Url>,
|
||||
) -> Result<&'a Url, InvalidRedirectUriError> {
|
||||
match (suggested_uri, &self.redirect_uris) {
|
||||
(None, None) => Err(InvalidRedirectUriError),
|
||||
(None, Some(redirect_uris)) => redirect_uris
|
||||
.iter()
|
||||
.next()
|
||||
.cloned()
|
||||
.ok_or(InvalidRedirectUriError),
|
||||
(None, Some(redirect_uris)) => {
|
||||
redirect_uris.iter().next().ok_or(InvalidRedirectUriError)
|
||||
}
|
||||
(Some(suggested_uri), None) => Ok(suggested_uri),
|
||||
(Some(suggested_uri), Some(redirect_uris)) => {
|
||||
if redirect_uris.contains(&suggested_uri) {
|
||||
|
||||
@@ -12,91 +12,231 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use std::borrow::BorrowMut;
|
||||
|
||||
use anyhow::Context;
|
||||
use argon2::Argon2;
|
||||
use password_hash::{PasswordHash, SaltString};
|
||||
use chrono::{DateTime, Utc};
|
||||
use password_hash::{PasswordHash, PasswordHasher, SaltString};
|
||||
use rand::rngs::OsRng;
|
||||
use serde::Serialize;
|
||||
use sqlx::{FromRow, PgPool};
|
||||
use sqlx::{Executor, FromRow, PgPool, Postgres, Transaction};
|
||||
use tracing::{info_span, Instrument};
|
||||
|
||||
#[derive(Serialize, Debug, Clone, FromRow)]
|
||||
pub struct User {
|
||||
id: i32,
|
||||
username: String,
|
||||
pub id: i32,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
impl User {
|
||||
#[derive(Serialize, Debug, Clone, FromRow)]
|
||||
pub struct SessionInfo {
|
||||
id: i32,
|
||||
user_id: i32,
|
||||
username: String,
|
||||
active: bool,
|
||||
created_at: DateTime<Utc>,
|
||||
last_authd_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl SessionInfo {
|
||||
pub fn key(&self) -> i32 {
|
||||
self.id
|
||||
}
|
||||
}
|
||||
|
||||
impl super::Storage<PgPool> {
|
||||
pub async fn login(&self, username: &str, password: &str) -> anyhow::Result<User> {
|
||||
let mut conn = self.pool.acquire().await?;
|
||||
|
||||
let (id, username, hashed_password): (i32, String, String) = sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, username, hashed_password
|
||||
FROM users
|
||||
WHERE username = $1
|
||||
"#,
|
||||
)
|
||||
.bind(&username)
|
||||
.fetch_one(&mut conn)
|
||||
.instrument(info_span!("Fetch user"))
|
||||
.await
|
||||
.context("could not find user")?;
|
||||
|
||||
let context = Argon2::default();
|
||||
let hasher = PasswordHash::new(&hashed_password)?;
|
||||
hasher.verify_password(&[&context], &password)?;
|
||||
|
||||
Ok(User { id, username })
|
||||
pub async fn login(&self, username: &str, password: &str) -> anyhow::Result<SessionInfo> {
|
||||
let mut txn = self.pool.begin().await?;
|
||||
let user = lookup_user_by_username(&mut txn, username).await?;
|
||||
let mut session = start_session(&mut txn, user).await?;
|
||||
session.last_authd_at = Some(authenticate_session(&mut txn, session.id, password).await?);
|
||||
txn.commit().await?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
pub async fn register_user(&self, username: &str, password: &str) -> anyhow::Result<User> {
|
||||
let context = Argon2::default();
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hashed_password = PasswordHash::generate(context, password, salt.as_str())?;
|
||||
|
||||
let mut conn = self.pool.acquire().await?;
|
||||
|
||||
let result: (i32,) = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO users (username, hashed_password)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(&username)
|
||||
.bind(&hashed_password.to_string())
|
||||
.fetch_one(&mut conn)
|
||||
.instrument(info_span!("Register user"))
|
||||
.await
|
||||
.context("could not insert user")?;
|
||||
|
||||
Ok(User {
|
||||
id: result.0,
|
||||
username: username.to_string(),
|
||||
})
|
||||
let hasher = Argon2::default();
|
||||
register_user(&mut conn, hasher, username, password).await
|
||||
}
|
||||
|
||||
pub async fn lookup_user(&self, id: i32) -> anyhow::Result<User> {
|
||||
pub async fn lookup_session(&self, id: i32) -> anyhow::Result<SessionInfo> {
|
||||
let mut conn = self.pool.acquire().await?;
|
||||
lookup_session(&mut conn, id).await
|
||||
}
|
||||
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, username
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(&mut conn)
|
||||
.instrument(info_span!("Fetch user"))
|
||||
.await
|
||||
.context("could not fetch user")
|
||||
pub async fn lookup_and_reauth_session(
|
||||
&self,
|
||||
session_id: i32,
|
||||
password: &str,
|
||||
) -> anyhow::Result<SessionInfo> {
|
||||
let mut txn = self.pool.begin().await?;
|
||||
let mut session = lookup_session(&mut txn, session_id).await?;
|
||||
session.last_authd_at = Some(authenticate_session(&mut txn, session.id, password).await?);
|
||||
txn.commit().await?;
|
||||
Ok(session)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn lookup_session(
|
||||
executor: impl Executor<'_, Database = Postgres>,
|
||||
id: i32,
|
||||
) -> anyhow::Result<SessionInfo> {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT
|
||||
s.id,
|
||||
u.id as user_id,
|
||||
u.username,
|
||||
s.active,
|
||||
s.created_at,
|
||||
a.created_at as last_authd_at
|
||||
FROM user_sessions s
|
||||
INNER JOIN users u
|
||||
ON s.user_id = u.id
|
||||
LEFT JOIN user_session_authentications a
|
||||
ON a.session_id = s.id
|
||||
WHERE s.id = $1
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
.context("could not fetch session")
|
||||
}
|
||||
|
||||
pub async fn start_session(
|
||||
executor: impl Executor<'_, Database = Postgres>,
|
||||
user: User,
|
||||
) -> anyhow::Result<SessionInfo> {
|
||||
let (id, created_at): (i32, DateTime<Utc>) = sqlx::query_as(
|
||||
r#"
|
||||
INSERT INTO user_sessions (user_id)
|
||||
VALUES ($1)
|
||||
RETURNING id, created_at
|
||||
"#,
|
||||
)
|
||||
.bind(user.id)
|
||||
.fetch_one(executor)
|
||||
.await
|
||||
.context("could not create session")?;
|
||||
|
||||
Ok(SessionInfo {
|
||||
id,
|
||||
user_id: user.id,
|
||||
username: user.username,
|
||||
active: true,
|
||||
created_at,
|
||||
last_authd_at: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn authenticate_session(
|
||||
txn: &mut Transaction<'_, Postgres>,
|
||||
session_id: i32,
|
||||
password: &str,
|
||||
) -> anyhow::Result<DateTime<Utc>> {
|
||||
// First, fetch the hashed password from the user associated with that session
|
||||
let hashed_password: String = sqlx::query_scalar(
|
||||
r#"
|
||||
SELECT u.hashed_password
|
||||
FROM user_sessions s
|
||||
INNER JOIN users u
|
||||
ON u.id = s.user_id
|
||||
WHERE s.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_one(txn.borrow_mut())
|
||||
.await
|
||||
.context("could not fetch user password hash")?;
|
||||
|
||||
// TODO: pass verifiers list as parameter
|
||||
let context = Argon2::default();
|
||||
let hasher = PasswordHash::new(&hashed_password)?;
|
||||
hasher.verify_password(&[&context], &password)?;
|
||||
|
||||
// That went well, let's insert the auth info
|
||||
let created_at: DateTime<Utc> = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO user_session_authentications (session_id)
|
||||
VALUES ($1)
|
||||
RETURNING created_at
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_one(txn.borrow_mut())
|
||||
.await
|
||||
.context("could not save session auth")?;
|
||||
|
||||
Ok(created_at)
|
||||
}
|
||||
|
||||
pub async fn register_user(
|
||||
executor: impl Executor<'_, Database = Postgres>,
|
||||
phf: impl PasswordHasher,
|
||||
username: &str,
|
||||
password: &str,
|
||||
) -> anyhow::Result<User> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let hashed_password = PasswordHash::generate(phf, password, salt.as_str())?;
|
||||
|
||||
let id: i32 = sqlx::query_scalar(
|
||||
r#"
|
||||
INSERT INTO users (username, hashed_password)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(&username)
|
||||
.bind(&hashed_password.to_string())
|
||||
.fetch_one(executor)
|
||||
.instrument(info_span!("Register user"))
|
||||
.await
|
||||
.context("could not insert user")?;
|
||||
|
||||
Ok(User {
|
||||
id,
|
||||
username: username.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn lookup_user_by_id(
|
||||
executor: impl Executor<'_, Database = Postgres>,
|
||||
id: i32,
|
||||
) -> anyhow::Result<User> {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, username
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(executor)
|
||||
.instrument(info_span!("Fetch user"))
|
||||
.await
|
||||
.context("could not fetch user")
|
||||
}
|
||||
|
||||
pub async fn lookup_user_by_username(
|
||||
executor: impl Executor<'_, Database = Postgres>,
|
||||
username: &str,
|
||||
) -> anyhow::Result<User> {
|
||||
sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, username
|
||||
FROM users
|
||||
WHERE username = $1
|
||||
"#,
|
||||
)
|
||||
.bind(&username)
|
||||
.fetch_one(executor)
|
||||
.instrument(info_span!("Fetch user"))
|
||||
.await
|
||||
.context("could not fetch user")
|
||||
}
|
||||
|
||||
@@ -31,10 +31,10 @@ pub async fn common_context(req: &Request<State>) -> Result<Context, anyhow::Err
|
||||
|
||||
let mut ctx = Context::new();
|
||||
|
||||
let user: Option<_> = session.get("current_user");
|
||||
if let Some(user) = user {
|
||||
let user = state.storage().lookup_user(user).await?;
|
||||
ctx.insert("current_user", &user);
|
||||
let session_id: Option<_> = session.get("current_session");
|
||||
if let Some(session_id) = session_id {
|
||||
let user = state.storage().lookup_session(session_id).await?;
|
||||
ctx.insert("current_session", &user);
|
||||
}
|
||||
|
||||
let token: Option<&CsrfToken> = req.ext();
|
||||
|
||||
@@ -32,9 +32,9 @@ limitations under the License.
|
||||
</div>
|
||||
|
||||
<div class="navbar-end">
|
||||
{% if current_user %}
|
||||
{% if current_session %}
|
||||
<div class="navbar-item">
|
||||
Howdy {{ current_user.username }}!
|
||||
Howdy {{ current_session.username }}!
|
||||
</div>
|
||||
<div class="navbar-item">
|
||||
<form method="POST" action="/logout">
|
||||
|
||||
@@ -40,8 +40,8 @@ limitations under the License.
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-link">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
{#
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
#}
|
||||
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="container is-max-desktop">
|
||||
<div class="columns">
|
||||
<div class="column is-one-third">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
|
||||
<div class="field">
|
||||
<label class="label" for="login-password">Password</label>
|
||||
<div class="control">
|
||||
<input class="input" name="password" id="login-password" type="password">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="control">
|
||||
<button type="submit" class="button is-link">Submit</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="column is-two-third">
|
||||
<pre><code>{{ current_session | json_encode(pretty=True) | safe }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock content %}
|
||||
|
||||
Reference in New Issue
Block a user