feat: enable login via email magic link

This commit is contained in:
Zack Fu Zi Xiang 2024-02-08 12:03:21 +08:00
parent 8def2bfaf3
commit b4fd4cea05
No known key found for this signature in database
GPG Key ID: 39DE600AFEEED522
10 changed files with 119 additions and 94 deletions

View File

@ -19,11 +19,12 @@ use gotrue::params::{
MagicLinkParams, MagicLinkParams,
}; };
use gotrue_entity::dto::{GotrueTokenResponse, SignUpResponse, UpdateGotrueUserParams, User}; use gotrue_entity::dto::{GotrueTokenResponse, SignUpResponse, UpdateGotrueUserParams, User};
use gotrue_entity::error::GoTrueError; use tracing::info;
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/login", post(login_handler)) .route("/signin", post(sign_in_handler))
.route("/signup", post(sign_up_handler))
.route("/login_refresh/:refresh_token", post(login_refresh_handler)) .route("/login_refresh/:refresh_token", post(login_refresh_handler))
.route("/logout", post(logout_handler)) .route("/logout", post(logout_handler))
@ -112,18 +113,14 @@ pub async fn open_app_handler(session: UserSession) -> Result<HeaderMap, WebApiE
// to the target user // to the target user
pub async fn invite_handler( pub async fn invite_handler(
State(state): State<AppState>, State(state): State<AppState>,
session: UserSession,
Form(param): Form<WebApiInviteUserRequest>, Form(param): Form<WebApiInviteUserRequest>,
) -> Result<WebApiResponse<()>, WebApiError<'static>> { ) -> Result<WebApiResponse<()>, WebApiError<'static>> {
state state
.gotrue_client .gotrue_client
.magic_link( .magic_link(&MagicLinkParams {
&session.token.access_token, email: param.email,
&MagicLinkParams { ..Default::default()
email: param.email, })
..Default::default()
},
)
.await?; .await?;
Ok(WebApiResponse::<()>::from_str("Invitation sent".into())) Ok(WebApiResponse::<()>::from_str("Invitation sent".into()))
} }
@ -275,13 +272,18 @@ pub async fn login_refresh_handler(
// login and set the cookie // login and set the cookie
// sign up if not exist // sign up if not exist
pub async fn login_handler( pub async fn sign_in_handler(
State(state): State<AppState>, State(state): State<AppState>,
jar: CookieJar, jar: CookieJar,
Form(param): Form<WebApiLoginRequest>, Form(param): Form<WebApiLoginRequest>,
) -> Result<(CookieJar, HeaderMap, WebApiResponse<()>), WebApiError<'static>> { ) -> Result<(CookieJar, HeaderMap, WebApiResponse<()>), WebApiError<'static>> {
if param.password.is_empty() {
let res = send_magic_link(State(state), &param.email).await?;
return Ok((CookieJar::new(), HeaderMap::new(), res));
}
// Attempt to sign in with email and password // Attempt to sign in with email and password
let token_res = state let token = state
.gotrue_client .gotrue_client
.token(&gotrue::grant::Grant::Password( .token(&gotrue::grant::Grant::Password(
gotrue::grant::PasswordGrant { gotrue::grant::PasswordGrant {
@ -289,48 +291,36 @@ pub async fn login_handler(
password: param.password.to_owned(), password: param.password.to_owned(),
}, },
)) ))
.await; .await?;
match token_res { session_login(State(state), token, jar).await
Ok(token) => session_login(State(state), token, jar).await, // login success }
Err(err) => match &err {
GoTrueError::ClientError(client_err) => {
match (
client_err.error.as_str(),
client_err.error_description.as_deref(),
) {
// Email not exist or wrong password
("invalid_grant", Some("Invalid login credentials")) => {
let sign_up_res = state
.gotrue_client
.sign_up_with_referrer(&param.email, &param.password, Some("/"))
.await;
match sign_up_res { pub async fn sign_up_handler(
Ok(resp) => match resp { State(state): State<AppState>,
// when GOTRUE_MAILER_AUTOCONFIRM=true, auto sign in jar: CookieJar,
SignUpResponse::Authenticated(token) => { Form(param): Form<WebApiLoginRequest>,
session_login(State(state), token, jar).await ) -> Result<(CookieJar, HeaderMap, WebApiResponse<()>), WebApiError<'static>> {
}, if param.password.is_empty() {
SignUpResponse::NotAuthenticated(user) => match user.identities { let res = send_magic_link(State(state), &param.email).await?;
Some(_identities) => { return Ok((CookieJar::new(), HeaderMap::new(), res));
// new user, awaiting email verification }
Ok((
jar, let sign_up_res = state
HeaderMap::new(), .gotrue_client
WebApiResponse::<()>::from_str("Email Verification Sent".into()), .sign_up_with_referrer(&param.email, &param.password, Some("/"))
)) .await?;
},
None => Err(err.into()), // user exists but sign in password not correct match sign_up_res {
}, // when GOTRUE_MAILER_AUTOCONFIRM=true, auto sign in
}, SignUpResponse::Authenticated(token) => session_login(State(state), token, jar).await,
Err(err) => Err(err.into()), SignUpResponse::NotAuthenticated(user) => {
} info!("user signed up and not authenticated: {:?}", user);
}, Ok((
_ => Err(err.into()), jar,
} HeaderMap::new(),
}, WebApiResponse::<()>::from_str("Email Verification Sent".into()),
_ => Err(err.into()), ))
}, },
} }
} }
@ -382,6 +372,22 @@ async fn session_login(
)) ))
} }
async fn send_magic_link(
State(state): State<AppState>,
email: &str,
) -> Result<WebApiResponse<()>, WebApiError<'static>> {
Ok(
state
.gotrue_client
.magic_link(&MagicLinkParams {
email: email.to_owned(),
..Default::default()
})
.await?
.into(),
)
}
fn get_base_url(header_map: &HeaderMap) -> String { fn get_base_url(header_map: &HeaderMap) -> String {
let scheme = get_header_value_or_default(header_map, "x-scheme", "http"); let scheme = get_header_value_or_default(header_map, "x-scheme", "http");
let host = get_header_value_or_default(header_map, "host", "localhost"); let host = get_header_value_or_default(header_map, "host", "localhost");

View File

@ -1,6 +1,6 @@
<div> <div>
<h4>Please enter the following information to create new SSO</h4> <h4>Please enter the following information to create new SSO</h4>
<form hx-post="/web-api/admin/sso" hx-target="#none"> <form hx-post="/web-api/admin/sso">
<table> <table>
<tr> <tr>
<td>Email</td> <td>Email</td>

View File

@ -2,7 +2,7 @@
{% include "user_details.html" %} {% include "user_details.html" %}
<div> <div>
<form hx-put="/web-api/admin/user/{{ user.id|escape }}" hx-target="#none"> <form hx-put="/web-api/admin/user/{{ user.id|escape }}">
<table> <table>
<tr> <tr>
<td>Set Password:</td> <td>Set Password:</td>

View File

@ -1,6 +1,6 @@
<div> <div>
<h3>Password Change</h3> <h3>Password Change</h3>
<form hx-post="/web-api/change_password" hx-target="#none"> <form hx-post="/web-api/change_password">
<table> <table>
<tr> <tr>
<td>New Password:</td> <td>New Password:</td>

View File

@ -1,6 +1,6 @@
<div id="create-user"> <div id="create-user">
<h4>Please enter the following information to create a new user</h4> <h4>Please enter the following information to create a new user</h4>
<form hx-post="/web-api/admin/user" hx-target="#none"> <form hx-post="/web-api/admin/user">
<table> <table>
<tr> <tr>
<td>Email:</td> <td>Email:</td>

View File

@ -1,6 +1,6 @@
<div id="invite-user"> <div id="invite-user">
<h4>Please enter the following email invite a new user</h4> <h4>Please enter the following email invite a new user</h4>
<form hx-post="/web-api/invite" hx-target="#none"> <form hx-post="/web-api/invite">
<table> <table>
<tr> <tr>
<td>Email:</td> <td>Email:</td>

View File

@ -24,18 +24,31 @@
</html> </html>
<script> <script>
function findButton(event) {
let button = event.target.querySelector(".button");
if (button) {
return button;
} else {
return event.target.closest(".button");
}
}
document.body.addEventListener("htmx:beforeRequest", function (event) { document.body.addEventListener("htmx:beforeRequest", function (event) {
const closeButton = event.target.querySelector(".button"); const closeButton = findButton(event);
closeButton.classList.add("loading-button"); if (closeButton) {
closeButton.disabled = true; closeButton.classList.add("loading-button");
closeButton.disabled = true;
}
}); });
document.body.addEventListener("htmx:afterRequest", function (evt) { document.body.addEventListener("htmx:afterRequest", function (event) {
const closeButton = event.target.querySelector(".button"); const closeButton = findButton(event);
closeButton.classList.remove("loading-button"); if (closeButton) {
closeButton.disabled = false; closeButton.classList.remove("loading-button");
closeButton.disabled = false;
}
const detail = evt.detail; const detail = event.detail;
if (detail.failed) { if (detail.failed) {
const xhr = detail.xhr; const xhr = detail.xhr;
displayHttpFail(xhr.status, xhr.statusText, xhr.responseText); displayHttpFail(xhr.status, xhr.statusText, xhr.responseText);

View File

@ -20,10 +20,10 @@
</div> </div>
<h3>Email Login</h3> <h3>Email Login</h3>
<form hx-post="/web-api/login" hx-target="#none"> <form>
<table style="width: 100%"> <table style="width: 100%">
<tr> <tr>
<td>Email:</td> <td>Email</td>
<td> <td>
<input <input
class="input" class="input"
@ -36,7 +36,7 @@
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Password:</td> <td>Password &nbsp</td>
<td> <td>
<input <input
class="input" class="input"
@ -44,19 +44,35 @@
type="password" type="password"
id="password" id="password"
name="password" name="password"
placeholder="********" placeholder="********(optional)"
/> />
</td> </td>
</tr> </tr>
</table> </table>
<button
class="button cyan" <small style="color: #888"
type="submit" ><i>
style="width: 100%; margin: 16px auto 0; padding: 8px 8px" (Magic link will be sent to email if password is not provided)
id="submitBtn" </i></small
> >
Sign In / Sign Up <div style="display: flex; margin: 8px 0px">
</button> <button
hx-post="/web-api/signin"
class="button cyan"
type="submit"
style="width: 100%; padding: 8px 8px"
>
Sign In
</button>
<button
hx-post="/web-api/signup"
class="button purple"
type="submit"
style="width: 100%; padding: 8px 8px"
>
Sign Up
</button>
</div>
</form> </form>
<!-- Load OAuth Providers if configured --> <!-- Load OAuth Providers if configured -->

View File

@ -374,23 +374,16 @@ impl Client {
pub async fn invite(&self, email: &str) -> Result<(), AppResponseError> { pub async fn invite(&self, email: &str) -> Result<(), AppResponseError> {
self self
.gotrue_client .gotrue_client
.magic_link( .magic_link(&MagicLinkParams {
&self.access_token()?, email: email.to_owned(),
&MagicLinkParams { ..Default::default()
email: email.to_owned(), })
..Default::default()
},
)
.await?; .await?;
Ok(()) Ok(())
} }
#[instrument(level = "debug", skip_all, err)] #[instrument(level = "debug", skip_all, err)]
pub async fn create_magic_link( pub async fn create_user(&self, email: &str, password: &str) -> Result<User, AppResponseError> {
&self,
email: &str,
password: &str,
) -> Result<User, AppResponseError> {
Ok( Ok(
self self
.gotrue_client .gotrue_client

View File

@ -224,14 +224,11 @@ impl Client {
to_gotrue_result(resp).await to_gotrue_result(resp).await
} }
pub async fn magic_link( pub async fn magic_link(&self, magic_link_params: &MagicLinkParams) -> Result<(), GoTrueError> {
&self,
access_token: &str,
magic_link_params: &MagicLinkParams,
) -> Result<(), GoTrueError> {
let url = format!("{}/magiclink", self.base_url); let url = format!("{}/magiclink", self.base_url);
let resp = self let resp = self
.http_client_with_auth(Method::POST, &url, access_token) .client
.request(Method::POST, &url)
.json(&magic_link_params) .json(&magic_link_params)
.send() .send()
.await?; .await?;