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

View File

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

View File

@ -4,7 +4,9 @@ import (
"code.smartsheep.studio/hydrogen/passport/pkg/database" "code.smartsheep.studio/hydrogen/passport/pkg/database"
"code.smartsheep.studio/hydrogen/passport/pkg/models" "code.smartsheep.studio/hydrogen/passport/pkg/models"
"code.smartsheep.studio/hydrogen/passport/pkg/services" "code.smartsheep.studio/hydrogen/passport/pkg/services"
"fmt"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/spf13/viper"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
@ -27,10 +29,19 @@ func doRegister(c *fiber.Ctx) error {
Nick string `json:"nick"` Nick string `json:"nick"`
Email string `json:"email"` Email string `json:"email"`
Password string `json:"password"` Password string `json:"password"`
MagicToken string `json:"magic_token"`
} }
if err := BindAndValidate(c, &data); err != nil { if err := BindAndValidate(c, &data); err != nil {
return err 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( if user, err := services.CreateAccount(

View File

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

View File

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

View File

@ -3,8 +3,26 @@ package services
import ( import (
"code.smartsheep.studio/hydrogen/passport/pkg/database" "code.smartsheep.studio/hydrogen/passport/pkg/database"
"code.smartsheep.studio/hydrogen/passport/pkg/models" "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) { func LookupFactor(id uint) (models.AuthFactor, error) {
var factor models.AuthFactor var factor models.AuthFactor
err := database.C.Where(models.AuthFactor{ err := database.C.Where(models.AuthFactor{
@ -22,3 +40,30 @@ func LookupFactorsByUser(uid uint) ([]models.AuthFactor, error) {
return factors, err 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" "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) { func NewMagicToken(mode models.MagicTokenType, assignTo *models.Account, expiredAt *time.Time) (models.MagicToken, error) {
var uid uint var uid uint
if assignTo != nil { if assignTo != nil {
@ -36,8 +63,8 @@ func NotifyMagicToken(token models.MagicToken) error {
} }
var user models.Account var user models.Account
if err := database.C.Where(&models.MagicToken{ if err := database.C.Where(&models.Account{
AssignTo: token.AssignTo, BaseModel: models.BaseModel{ID: *token.AssignTo},
}).Preload("Contacts").First(&user).Error; err != nil { }).Preload("Contacts").First(&user).Error; err != nil {
return err return err
} }
@ -46,15 +73,16 @@ func NotifyMagicToken(token models.MagicToken) error {
var content string var content string
switch token.Type { switch token.Type {
case models.ConfirmMagicToken: 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")) subject = fmt.Sprintf("[%s] Confirm your registration", viper.GetString("name"))
content = fmt.Sprintf("We got a create account request with this email recently.\n"+ content = fmt.Sprintf(
"So we need you to click the link below to confirm your registeration.\n"+ ConfirmRegistrationTemplate,
"Confirmnation Link: %s\n"+ user.Name,
"If you didn't do that, you can ignore this email.\n\n"+ viper.GetString("name"),
"%s\n"+ viper.GetString("maintainer"),
"Best wishes", link,
link, viper.GetString("maintainer")) viper.GetString("maintainer"),
)
default: default:
return fmt.Errorf("unsupported magic token type to notify") 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" domain = "id.smartsheep.studio"
secret = "LtTjzAGFLshwXhN4ZD4nG5KlMv1MWcsvfv03TSZYnT1VhiAnLIZFTnHUwR0XhGgi" 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] [database]
dsn = "host=localhost dbname=hy_passport port=5432 sslmode=disable" dsn = "host=localhost dbname=hy_passport port=5432 sslmode=disable"
prefix = "passport_" prefix = "passport_"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { createSignal, Show } from "solid-js"; import { createSignal, Show } from "solid-js";
import { useWellKnown } from "../../stores/wellKnown.tsx";
export default function RegisterPage() { export default function RegisterPage() {
const [title, setTitle] = createSignal("Create an account"); const [title, setTitle] = createSignal("Create an account");
@ -8,6 +9,8 @@ export default function RegisterPage() {
const [loading, setLoading] = createSignal(false); const [loading, setLoading] = createSignal(false);
const [done, setDone] = createSignal(false); const [done, setDone] = createSignal(false);
const metadata = useWellKnown();
async function submit(evt: SubmitEvent) { async function submit(evt: SubmitEvent) {
evt.preventDefault(); evt.preventDefault();
@ -23,6 +26,7 @@ export default function RegisterPage() {
if (res.status !== 200) { if (res.status !== 200) {
setError(await res.text()); setError(await res.text());
} else { } else {
setError(null);
setTitle("Congratulations!"); setTitle("Congratulations!");
setSubtitle("Your account has been created and activation email has sent to your inbox!"); setSubtitle("Your account has been created and activation email has sent to your inbox!");
setDone(true); setDone(true);
@ -32,6 +36,7 @@ export default function RegisterPage() {
return ( return (
<div class="w-full h-full flex justify-center items-center"> <div class="w-full h-full flex justify-center items-center">
<div>
<div class="card w-[480px] max-w-screen shadow-xl"> <div class="card w-[480px] max-w-screen shadow-xl">
<div class="card-body"> <div class="card-body">
<div id="header" class="text-center mb-5"> <div id="header" class="text-center mb-5">
@ -86,11 +91,26 @@ export default function RegisterPage() {
<div class="label"> <div class="label">
<span class="label-text">Password</span> <span class="label-text">Password</span>
</div> </div>
<input name="password" type="password" placeholder="Type here" class="input input-bordered w-full" /> <input name="password" type="password" placeholder="Type here"
class="input input-bordered w-full" />
<div class="label"> <div class="label">
<span class="label-text-alt">Must be secure</span> <span class="label-text-alt">Must be secure</span>
</div> </div>
</label> </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 type="submit" class="btn btn-primary btn-block mt-3" disabled={loading()}> <button type="submit" class="btn btn-primary btn-block mt-3" disabled={loading()}>
<Show when={loading()} fallback={"Next"}> <Show when={loading()} fallback={"Next"}>
@ -110,6 +130,7 @@ export default function RegisterPage() {
</div> </div>
</Show> </Show>
</div> </div>
</div>
<div class="text-sm text-center mt-3"> <div class="text-sm text-center mt-3">
<a href="/auth/login" class="link">Already had an account? Login now!</a> <a href="/auth/login" class="link">Already had an account? Login now!</a>

View File

@ -1,5 +1,6 @@
import { createSignal, Show } from "solid-js"; 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() { export default function ConfirmRegistrationPage() {
const [error, setError] = createSignal<string | null>(null); const [error, setError] = createSignal<string | null>(null);
@ -7,6 +8,8 @@ export default function ConfirmRegistrationPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate();
async function doConfirm() { async function doConfirm() {
if (!searchParams["tk"]) { if (!searchParams["tk"]) {
setError("Bad Request: Code was not exists"); setError("Bad Request: Code was not exists");
@ -23,6 +26,8 @@ export default function ConfirmRegistrationPage() {
setError(await res.text()); setError(await res.text());
} else { } else {
setStatus("Confirmed. Redirecting to dashboard..."); 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()], plugins: [devtools({ autoname: true }), solid()],
server: { server: {
proxy: { proxy: {
"/api": "http://localhost:8444" "/api": "http://localhost:8444",
"/.well-known": "http://localhost:8444"
} }
} }
}); });