Email notification

This commit is contained in:
LittleSheep 2024-01-29 16:11:59 +08:00
parent 20119cb177
commit 3c58cb8f0a
18 changed files with 280 additions and 127 deletions

2
.gitignore vendored
View File

@ -1 +1 @@
/dist
/dist

View File

@ -1,22 +1,11 @@
package security
import (
"fmt"
"code.smartsheep.studio/hydrogen/passport/pkg/models"
"fmt"
"github.com/samber/lo"
)
func GetFactorCode(factor models.AuthFactor) (bool, error) {
switch factor.Type {
case models.EmailPasswordFactor:
// TODO
return true, nil
default:
return false, nil
}
}
func VerifyFactor(factor models.AuthFactor, code string) error {
switch factor.Type {
case models.PasswordAuthFactor:
@ -25,6 +14,12 @@ func VerifyFactor(factor models.AuthFactor, code string) error {
nil,
fmt.Errorf("invalid password"),
)
case models.EmailPasswordFactor:
return lo.Ternary(
code == factor.Secret,
nil,
fmt.Errorf("invalid verification code"),
)
}
return nil

View File

@ -4,7 +4,9 @@ import (
"code.smartsheep.studio/hydrogen/passport/pkg/database"
"code.smartsheep.studio/hydrogen/passport/pkg/models"
"code.smartsheep.studio/hydrogen/passport/pkg/services"
"fmt"
"github.com/gofiber/fiber/v2"
"github.com/spf13/viper"
"gorm.io/gorm/clause"
)
@ -23,14 +25,23 @@ func getPrincipal(c *fiber.Ctx) error {
func doRegister(c *fiber.Ctx) error {
var data struct {
Name string `json:"name"`
Nick string `json:"nick"`
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
Nick string `json:"nick"`
Email string `json:"email"`
Password string `json:"password"`
MagicToken string `json:"magic_token"`
}
if err := BindAndValidate(c, &data); err != nil {
return err
} else if viper.GetBool("use_registration_magic_token") && len(data.MagicToken) <= 0 {
return fmt.Errorf("missing magic token in request")
}
if tk, err := services.ValidateMagicToken(data.MagicToken, models.RegistrationMagicToken); err != nil {
return err
} else {
database.C.Delete(&tk)
}
if user, err := services.CreateAccount(

View File

@ -1,7 +1,6 @@
package server
import (
"code.smartsheep.studio/hydrogen/passport/pkg/security"
"code.smartsheep.studio/hydrogen/passport/pkg/services"
"github.com/gofiber/fiber/v2"
)
@ -14,7 +13,7 @@ func requestFactorToken(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusNotFound, err.Error())
}
if sent, err := security.GetFactorCode(factor); err != nil {
if sent, err := services.GetFactorCode(factor); err != nil {
return fiber.NewError(fiber.StatusNotFound, err.Error())
} else if !sent {
return c.SendStatus(fiber.StatusNoContent)

View File

@ -7,7 +7,8 @@ import (
func getMetadata(c *fiber.Ctx) error {
return c.JSON(fiber.Map{
"name": viper.GetString("name"),
"domain": viper.GetString("domain"),
"name": viper.GetString("name"),
"domain": viper.GetString("domain"),
"open_registration": !viper.GetBool("use_registration_magic_token"),
})
}

View File

@ -5,6 +5,7 @@ import (
"code.smartsheep.studio/hydrogen/passport/pkg/models"
"code.smartsheep.studio/hydrogen/passport/pkg/security"
"fmt"
"github.com/google/uuid"
"github.com/samber/lo"
"gorm.io/datatypes"
"gorm.io/gorm"
@ -54,11 +55,16 @@ func CreateAccount(name, nick, email, password string) (models.Account, error) {
Type: models.PasswordAuthFactor,
Secret: security.HashPassword(password),
},
{
Type: models.EmailPasswordFactor,
Secret: uuid.NewString()[:8],
},
},
Contacts: []models.AccountContact{
{
Type: models.EmailAccountContact,
Content: email,
IsPrimary: true,
VerifiedAt: nil,
},
},
@ -80,14 +86,9 @@ func CreateAccount(name, nick, email, password string) (models.Account, error) {
}
func ConfirmAccount(code string) error {
var token models.MagicToken
if err := database.C.Where(&models.MagicToken{
Code: code,
Type: models.ConfirmMagicToken,
}).First(&token).Error; err != nil {
token, err := ValidateMagicToken(code, models.ConfirmMagicToken)
if err != nil {
return err
} else if token.AssignTo == nil {
return fmt.Errorf("account was not found")
}
var user models.Account

View File

@ -3,8 +3,26 @@ package services
import (
"code.smartsheep.studio/hydrogen/passport/pkg/database"
"code.smartsheep.studio/hydrogen/passport/pkg/models"
"fmt"
"github.com/google/uuid"
"github.com/spf13/viper"
)
const EmailPasswordTemplate = `Dear %s,
We hope this message finds you well.
As part of our ongoing commitment to ensuring the security of your account, we require you to complete the login process by entering the verification code below:
Your Login Verification Code: %s
Please use the provided code within the next 2 hours to complete your login.
If you did not request this code, please update your information, maybe your username or email has been leak.
Thank you for your cooperation in helping us maintain the security of your account.
Best regards,
%s`
func LookupFactor(id uint) (models.AuthFactor, error) {
var factor models.AuthFactor
err := database.C.Where(models.AuthFactor{
@ -22,3 +40,30 @@ func LookupFactorsByUser(uid uint) ([]models.AuthFactor, error) {
return factors, err
}
func GetFactorCode(factor models.AuthFactor) (bool, error) {
switch factor.Type {
case models.EmailPasswordFactor:
var user models.Account
if err := database.C.Where(&models.Account{
BaseModel: models.BaseModel{ID: factor.AccountID},
}).Preload("Contacts").First(&user).Error; err != nil {
return true, err
}
factor.Secret = uuid.NewString()[:8]
if err := database.C.Save(&factor).Error; err != nil {
return true, err
}
subject := fmt.Sprintf("[%s] Login verification code", viper.GetString("name"))
content := fmt.Sprintf(EmailPasswordTemplate, user.Name, factor.Secret, viper.GetString("maintainer"))
if err := SendMail(user.GetPrimaryEmail().Content, subject, content); err != nil {
return true, err
}
return true, nil
default:
return false, nil
}
}

View File

@ -10,6 +10,33 @@ import (
"time"
)
const ConfirmRegistrationTemplate = `Dear %s,
Thank you for choosing to register with %s. We are excited to welcome you to our community and appreciate your trust in us.
Your registration details have been successfully received, and you are now a valued member of %s. Here are the confirm link of your registration:
%s
As a confirmed registered member, you will have access to all our services.
We encourage you to explore our services and take full advantage of the resources available to you.
Once again, thank you for choosing us. We look forward to serving you and hope you have a positive experience with us.
Best regards,
%s`
func ValidateMagicToken(code string, mode models.MagicTokenType) (models.MagicToken, error) {
var tk models.MagicToken
if err := database.C.Where(models.MagicToken{Code: code, Type: mode}).First(&tk).Error; err != nil {
return tk, err
} else if tk.ExpiredAt != nil && time.Now().Unix() >= tk.ExpiredAt.Unix() {
return tk, fmt.Errorf("token has been expired")
}
return tk, nil
}
func NewMagicToken(mode models.MagicTokenType, assignTo *models.Account, expiredAt *time.Time) (models.MagicToken, error) {
var uid uint
if assignTo != nil {
@ -36,8 +63,8 @@ func NotifyMagicToken(token models.MagicToken) error {
}
var user models.Account
if err := database.C.Where(&models.MagicToken{
AssignTo: token.AssignTo,
if err := database.C.Where(&models.Account{
BaseModel: models.BaseModel{ID: *token.AssignTo},
}).Preload("Contacts").First(&user).Error; err != nil {
return err
}
@ -46,15 +73,16 @@ func NotifyMagicToken(token models.MagicToken) error {
var content string
switch token.Type {
case models.ConfirmMagicToken:
link := fmt.Sprintf("%s/users/me/confirm?tk=%s", viper.GetString("domain"), token.Code)
link := fmt.Sprintf("https://%s/users/me/confirm?tk=%s", viper.GetString("domain"), token.Code)
subject = fmt.Sprintf("[%s] Confirm your registration", viper.GetString("name"))
content = fmt.Sprintf("We got a create account request with this email recently.\n"+
"So we need you to click the link below to confirm your registeration.\n"+
"Confirmnation Link: %s\n"+
"If you didn't do that, you can ignore this email.\n\n"+
"%s\n"+
"Best wishes",
link, viper.GetString("maintainer"))
content = fmt.Sprintf(
ConfirmRegistrationTemplate,
user.Name,
viper.GetString("name"),
viper.GetString("maintainer"),
link,
viper.GetString("maintainer"),
)
default:
return fmt.Errorf("unsupported magic token type to notify")
}

View File

@ -7,6 +7,15 @@ bind = "0.0.0.0:8444"
domain = "id.smartsheep.studio"
secret = "LtTjzAGFLshwXhN4ZD4nG5KlMv1MWcsvfv03TSZYnT1VhiAnLIZFTnHUwR0XhGgi"
use_registration_magic_token = true
[mailer]
name = "Alphabot <alphabot@smartsheep.studio>"
smtp_host = "smtp.exmail.qq.com"
smtp_port = 465
username = "alphabot@smartsheep.studio"
password = "gz937Zxxzfcd9SeH"
[database]
dsn = "host=localhost dbname=hy_passport port=5432 sslmode=disable"
prefix = "passport_"

View File

@ -5,5 +5,4 @@
html, body {
padding: 0;
margin: 0;
height: 100vh;
}

View File

@ -9,14 +9,20 @@ import { lazy } from "solid-js";
import { Route, Router } from "@solidjs/router";
import RootLayout from "./layouts/RootLayout.tsx";
import { UserinfoProvider } from "./stores/userinfo.tsx";
import { WellKnownProvider } from "./stores/wellKnown.tsx";
const root = document.getElementById("root");
render(() => (
<Router root={RootLayout}>
<Route path="/" component={lazy(() => import("./pages/dashboard.tsx"))} />
<Route path="/auth/login" component={lazy(() => import("./pages/auth/login.tsx"))} />
<Route path="/auth/register" component={lazy(() => import("./pages/auth/register.tsx"))} />
<Route path="/users/me/confirm" component={lazy(() => import("./pages/users/confirm.tsx"))} />
</Router>
<WellKnownProvider>
<UserinfoProvider>
<Router root={RootLayout}>
<Route path="/" component={lazy(() => import("./pages/dashboard.tsx"))} />
<Route path="/auth/login" component={lazy(() => import("./pages/auth/login.tsx"))} />
<Route path="/auth/register" component={lazy(() => import("./pages/auth/register.tsx"))} />
<Route path="/users/me/confirm" component={lazy(() => import("./pages/users/confirm.tsx"))} />
</Router>
</UserinfoProvider>
</WellKnownProvider>
), root!);

View File

@ -1,11 +1,12 @@
import Navbar from "./shared/Navbar.tsx";
import { readProfiles, UserinfoProvider } from "../stores/userinfo.tsx";
import { readProfiles } from "../stores/userinfo.tsx";
import { createSignal, Show } from "solid-js";
import { readWellKnown } from "../stores/wellKnown.tsx";
export default function RootLayout(props: any) {
const [ready, setReady] = createSignal(false);
readProfiles().then(() => setReady(true));
Promise.all([readWellKnown(), readProfiles()]).then(() => setReady(true));
return (
<Show when={ready()} fallback={
@ -15,11 +16,8 @@ export default function RootLayout(props: any) {
</div>
</div>
}>
<UserinfoProvider>
<Navbar />
<main class="h-[calc(100vh-68px)]">{props.children}</main>
</UserinfoProvider>
<Navbar />
<main class="h-[calc(100vh-68px)] mt-[68px]">{props.children}</main>
</Show>
);
}

View File

@ -1,6 +1,7 @@
import { For, Match, Switch } from "solid-js";
import { clearUserinfo, useUserinfo } from "../../stores/userinfo.tsx";
import { useNavigate } from "@solidjs/router";
import { useWellKnown } from "../../stores/wellKnown.tsx";
interface MenuItem {
label: string;
@ -10,6 +11,7 @@ interface MenuItem {
export default function Navbar() {
const nav: MenuItem[] = [{ label: "Dashboard", href: "/" }];
const wellKnown = useWellKnown();
const userinfo = useUserinfo();
const navigate = useNavigate();
@ -19,7 +21,7 @@ export default function Navbar() {
}
return (
<div class="navbar bg-base-100 shadow-md px-5">
<div class="navbar bg-base-100 shadow-md px-5 z-10 fixed top-0">
<div class="navbar-start">
<div class="dropdown">
<div tabIndex={0} role="button" class="btn btn-ghost lg:hidden">
@ -52,7 +54,7 @@ export default function Navbar() {
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl">
Goatpass
{wellKnown?.name ?? "Goatpass"}
</a>
</div>
<div class="navbar-center hidden lg:flex">

View File

@ -50,7 +50,7 @@ export default function LoginPage() {
if (!data.factor) return;
setLoading(true);
const res = await fetch(`/api/auth/factors/${data.id}`, {
const res = await fetch(`/api/auth/factors/${data.factor}`, {
method: "POST"
});
if (res.status !== 200 && res.status !== 204) {
@ -93,6 +93,7 @@ export default function LoginPage() {
setStage("choosing");
setTitle("Continue verifying");
setSubtitle("You passed one check, but that's not enough.");
setChallenge(data["challenge"]);
}
}
setLoading(false);
@ -120,10 +121,17 @@ export default function LoginPage() {
}
}
function getFactorAvailable(factor: any) {
const blacklist: number[] = challenge()?.blacklist_factors ?? [];
return blacklist.includes(factor.id);
}
function getFactorName(factor: any) {
switch (factor.type) {
case 0:
return "Password Verification";
case 1:
return "Email Verification Code";
default:
return "Unknown";
}
@ -171,6 +179,7 @@ export default function LoginPage() {
{item =>
<input class="join-item btn" type="radio" name="factor"
value={item.id}
disabled={getFactorAvailable(item)}
aria-label={getFactorName(item)}
/>
}

View File

@ -1,4 +1,5 @@
import { createSignal, Show } from "solid-js";
import { useWellKnown } from "../../stores/wellKnown.tsx";
export default function RegisterPage() {
const [title, setTitle] = createSignal("Create an account");
@ -8,6 +9,8 @@ export default function RegisterPage() {
const [loading, setLoading] = createSignal(false);
const [done, setDone] = createSignal(false);
const metadata = useWellKnown();
async function submit(evt: SubmitEvent) {
evt.preventDefault();
@ -23,6 +26,7 @@ export default function RegisterPage() {
if (res.status !== 200) {
setError(await res.text());
} else {
setError(null);
setTitle("Congratulations!");
setSubtitle("Your account has been created and activation email has sent to your inbox!");
setDone(true);
@ -32,83 +36,100 @@ export default function RegisterPage() {
return (
<div class="w-full h-full flex justify-center items-center">
<div class="card w-[480px] max-w-screen shadow-xl">
<div class="card-body">
<div id="header" class="text-center mb-5">
<h1 class="text-xl font-bold">{title()}</h1>
<p>{subtitle()}</p>
</div>
<div>
<div class="card w-[480px] max-w-screen shadow-xl">
<div class="card-body">
<div id="header" class="text-center mb-5">
<h1 class="text-xl font-bold">{title()}</h1>
<p>{subtitle()}</p>
</div>
<Show when={error()}>
<div id="alerts" class="mt-1">
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
<Show when={error()}>
<div id="alerts" class="mt-1">
<div role="alert" class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="capitalize">{error()}</span>
</div>
</div>
</div>
</Show>
</Show>
<Show when={!done()}>
<form id="form" onSubmit={submit}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Username</span>
<span class="label-text-alt font-bold">Cannot be modify</span>
</div>
<input name="name" type="text" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Lowercase alphabet and numbers only, maximum 16 characters</span>
</div>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Nickname</span>
</div>
<input name="nick" type="text" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Maximum length is 24 characters</span>
</div>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Email Address</span>
</div>
<input name="email" type="email" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Do not accept address with plus sign</span>
</div>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Password</span>
</div>
<input name="password" type="password" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Must be secure</span>
</div>
</label>
<button type="submit" class="btn btn-primary btn-block mt-3" disabled={loading()}>
<Show when={loading()} fallback={"Next"}>
<span class="loading loading-spinner"></span>
<Show when={!done()}>
<form id="form" onSubmit={submit}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Username</span>
<span class="label-text-alt font-bold">Cannot be modify</span>
</div>
<input name="name" type="text" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Lowercase alphabet and numbers only, maximum 16 characters</span>
</div>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Nickname</span>
</div>
<input name="nick" type="text" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Maximum length is 24 characters</span>
</div>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Email Address</span>
</div>
<input name="email" type="email" placeholder="Type here" class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Do not accept address with plus sign</span>
</div>
</label>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Password</span>
</div>
<input name="password" type="password" placeholder="Type here"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">Must be secure</span>
</div>
</label>
<Show when={!metadata?.open_registration}>
<label class="form-control w-full">
<div class="label">
<span class="label-text">Magic Token</span>
</div>
<input name="magic_token" type="password" placeholder="Type here"
class="input input-bordered w-full" />
<div class="label">
<span class="label-text-alt">
This server enabled invitation only, so you need a magic token.
</span>
</div>
</label>
</Show>
</button>
</form>
</Show>
<Show when={done()}>
<div class="py-12 text-center">
<h2 class="text-lg font-bold">What's next?</h2>
<span>
<button type="submit" class="btn btn-primary btn-block mt-3" disabled={loading()}>
<Show when={loading()} fallback={"Next"}>
<span class="loading loading-spinner"></span>
</Show>
</button>
</form>
</Show>
<Show when={done()}>
<div class="py-12 text-center">
<h2 class="text-lg font-bold">What's next?</h2>
<span>
<a href="/auth/login" class="link">Go login</a>{" "}
then you can take part in the entire smartsheep community.
then you can take part in the entire smartsheep community.
</span>
</div>
</Show>
</div>
</Show>
</div>
</div>
<div class="text-sm text-center mt-3">

View File

@ -1,5 +1,6 @@
import { createSignal, Show } from "solid-js";
import { useSearchParams } from "@solidjs/router";
import { useNavigate, useSearchParams } from "@solidjs/router";
import { readProfiles } from "../../stores/userinfo.tsx";
export default function ConfirmRegistrationPage() {
const [error, setError] = createSignal<string | null>(null);
@ -7,6 +8,8 @@ export default function ConfirmRegistrationPage() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
async function doConfirm() {
if (!searchParams["tk"]) {
setError("Bad Request: Code was not exists");
@ -23,6 +26,8 @@ export default function ConfirmRegistrationPage() {
setError(await res.text());
} else {
setStatus("Confirmed. Redirecting to dashboard...");
await readProfiles();
navigate("/");
}
}

View File

@ -0,0 +1,23 @@
import { createContext, useContext } from "solid-js";
import { createStore } from "solid-js/store";
const WellKnownContext = createContext<any>();
const [wellKnown, setWellKnown] = createStore<any>(null);
export async function readWellKnown() {
const res = await fetch("/.well-known")
setWellKnown(await res.json())
}
export function WellKnownProvider(props: any) {
return (
<WellKnownContext.Provider value={wellKnown}>
{props.children}
</WellKnownContext.Provider>
);
}
export function useWellKnown() {
return useContext(WellKnownContext);
}

View File

@ -6,7 +6,8 @@ export default defineConfig({
plugins: [devtools({ autoname: true }), solid()],
server: {
proxy: {
"/api": "http://localhost:8444"
"/api": "http://localhost:8444",
"/.well-known": "http://localhost:8444"
}
}
});