Login

This commit is contained in:
LittleSheep 2024-02-02 00:53:22 +08:00
parent 434773976f
commit 19e1775476
15 changed files with 236 additions and 165 deletions

12
go.mod
View File

@ -6,10 +6,16 @@ require (
code.smartsheep.studio/hydrogen/passport v0.0.0-20240201075828-dbc09bd7af8a
github.com/go-playground/validator/v10 v10.17.0
github.com/gofiber/fiber/v2 v2.52.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/google/uuid v1.5.0
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
github.com/json-iterator/go v1.1.12
github.com/rs/zerolog v1.31.0
github.com/samber/lo v1.39.0
github.com/spf13/viper v1.18.2
golang.org/x/crypto v0.18.0
golang.org/x/oauth2 v0.16.0
gorm.io/datatypes v1.2.0
gorm.io/driver/postgres v1.5.4
gorm.io/gorm v1.25.6
)
@ -21,9 +27,7 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.5.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
@ -31,7 +35,6 @@ require (
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible // indirect
github.com/klauspost/compress v1.17.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
@ -56,10 +59,8 @@ require (
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.18.0 // indirect
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 // indirect
golang.org/x/net v0.20.0 // indirect
golang.org/x/oauth2 v0.16.0 // indirect
golang.org/x/sync v0.5.0 // indirect
golang.org/x/sys v0.16.0 // indirect
golang.org/x/text v0.14.0 // indirect
@ -67,6 +68,5 @@ require (
google.golang.org/protobuf v1.32.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/datatypes v1.2.0 // indirect
gorm.io/driver/mysql v1.5.2 // indirect
)

4
go.sum
View File

@ -146,8 +146,6 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/exp v0.0.0-20231219180239-dc181d75b848 h1:+iq7lrkxmFNBM7xx+Rae2W6uyPfhPeDWD+n+JgppptE=
@ -158,8 +156,6 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ=

View File

@ -42,11 +42,11 @@ func main() {
go server.Listen()
// Messages
log.Info().Msgf("Passport v%s is started...", interactive.AppVersion)
log.Info().Msgf("Interactive v%s is started...", interactive.AppVersion)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Info().Msgf("Passport v%s is quitting...", interactive.AppVersion)
log.Info().Msgf("Interactive v%s is quitting...", interactive.AppVersion)
}

View File

@ -11,19 +11,24 @@ import (
"golang.org/x/oauth2"
)
var cfg = oauth2.Config{
RedirectURL: fmt.Sprintf("https://%s/api/auth/callback", viper.GetString("domain")),
ClientID: viper.GetString("passport.client_id"),
ClientSecret: viper.GetString("passport.client_secret"),
Scopes: []string{"openid"},
Endpoint: oauth2.Endpoint{
AuthURL: fmt.Sprintf("%s/auth/o/connect", viper.GetString("passport.endpoint")),
TokenURL: fmt.Sprintf("%s/api/auth/token", viper.GetString("passport.endpoint")),
AuthStyle: oauth2.AuthStyleInParams,
},
var cfg oauth2.Config
func buildOauth2Config() {
cfg = oauth2.Config{
RedirectURL: fmt.Sprintf("https://%s/auth/callback", viper.GetString("domain")),
ClientID: viper.GetString("passport.client_id"),
ClientSecret: viper.GetString("passport.client_secret"),
Scopes: []string{"openid"},
Endpoint: oauth2.Endpoint{
AuthURL: fmt.Sprintf("%s/auth/o/connect", viper.GetString("passport.endpoint")),
TokenURL: fmt.Sprintf("%s/api/auth/token", viper.GetString("passport.endpoint")),
AuthStyle: oauth2.AuthStyleInParams,
},
}
}
func doLogin(c *fiber.Ctx) error {
buildOauth2Config()
url := cfg.AuthCodeURL(uuid.NewString())
return c.JSON(fiber.Map{
@ -32,6 +37,7 @@ func doLogin(c *fiber.Ctx) error {
}
func doPostLogin(c *fiber.Ctx) error {
buildOauth2Config()
code := c.Query("code")
token, err := cfg.Exchange(context.Background(), code)

View File

@ -56,6 +56,8 @@ func NewServer() {
api := A.Group("/api").Name("API")
{
api.Get("/users/me", auth, getUserinfo)
api.Get("/auth", doLogin)
api.Get("/auth/callback", doPostLogin)
api.Post("/auth/refresh", doRefreshToken)

20
pkg/server/users_api.go Normal file
View File

@ -0,0 +1,20 @@
package server
import (
"code.smartsheep.studio/hydrogen/interactive/pkg/database"
"code.smartsheep.studio/hydrogen/interactive/pkg/models"
"github.com/gofiber/fiber/v2"
)
func getUserinfo(c *fiber.Ctx) error {
user := c.Locals("principal").(models.Account)
var data models.Account
if err := database.C.
Where(&models.Account{BaseModel: models.BaseModel{ID: user.ID}}).
First(&data).Error; err != nil {
return fiber.NewError(fiber.StatusInternalServerError, err.Error())
}
return c.JSON(data)
}

View File

@ -13,24 +13,26 @@ import (
)
type PassportUserinfo struct {
Sub uint `json:"sub"`
Email string `json:"email"`
Picture string `json:"picture"`
PreferredUsernames string `json:"preferred_usernames"`
Sub string `json:"sub"`
Email string `json:"email"`
Picture string `json:"picture"`
PreferredUsername string `json:"preferred_username"`
}
func LinkAccount(userinfo PassportUserinfo) (models.Account, error) {
id, _ := strconv.Atoi(userinfo.Sub)
var account models.Account
if err := database.C.Where(&models.Account{
ExternalID: userinfo.Sub,
ExternalID: uint(id),
}).First(&account).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
account = models.Account{
Name: userinfo.PreferredUsernames,
Name: userinfo.PreferredUsername,
Avatar: userinfo.Picture,
EmailAddress: userinfo.Email,
PowerLevel: 0,
ExternalID: userinfo.Sub,
ExternalID: uint(id),
}
return account, database.C.Save(&account).Error
}

View File

@ -19,13 +19,8 @@ render(() => (
<UserinfoProvider>
<Router root={RootLayout}>
<Route path="/" component={lazy(() => import("./pages/dashboard.tsx"))} />
<Route path="/security" component={lazy(() => import("./pages/security.tsx"))} />
<Route path="/personalise" component={lazy(() => import("./pages/personalise.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/oauth/connect" component={lazy(() => import("./pages/auth/connect.tsx"))} />
<Route path="/auth/oauth/callback" component={lazy(() => import("./pages/auth/callback.tsx"))} />
<Route path="/users/me/confirm" component={lazy(() => import("./pages/users/confirm.tsx"))} />
<Route path="/auth" component={lazy(() => import("./pages/auth/callout.tsx"))} />
<Route path="/auth/callback" component={lazy(() => import("./pages/auth/callback.tsx"))} />
</Router>
</UserinfoProvider>
</WellKnownProvider>

View File

@ -21,7 +21,7 @@ export default function RootLayout(props: any) {
}, [ready, userinfo]);
function keepGate(path: string, e?: BeforeLeaveEventArgs) {
const whitelist = ["/auth/login", "/auth/register", "/users/me/confirm"];
const whitelist = ["/auth", "/auth/callback"];
if (!userinfo?.isLoggedIn && !whitelist.includes(path)) {
if (!e?.defaultPrevented) e?.preventDefault();

View File

@ -74,7 +74,7 @@ export default function Navbar() {
</ul>
</div>
<a href="/" class="btn btn-ghost text-xl">
{wellKnown?.name ?? "Goatpass"}
{wellKnown?.name ?? "Interactive"}
</a>
</div>
<div class="navbar-center hidden lg:flex">
@ -109,7 +109,7 @@ export default function Navbar() {
<button type="button" class="btn btn-sm btn-ghost" onClick={() => logout()}>Logout</button>
</Match>
<Match when={!userinfo?.isLoggedIn}>
<a href="/auth/login" class="btn btn-sm btn-primary">Login</a>
<a href="/auth" class="btn btn-sm btn-primary">Login</a>
</Match>
</Switch>
</div>

View File

@ -0,0 +1,64 @@
import { createSignal, Show } from "solid-js";
import { readProfiles } from "../../stores/userinfo.tsx";
import { useNavigate } from "@solidjs/router";
import Cookie from "universal-cookie";
export default function AuthCallback() {
const [error, setError] = createSignal<string | null>(null);
const [status, setStatus] = createSignal("Communicating with Goatpass...");
const navigate = useNavigate();
async function callback() {
const res = await fetch(`/api/auth/callback${location.search}`);
if (res.status !== 200) {
setError(await res.text());
} else {
const data = await res.json();
new Cookie().set("access_token", data["access_token"], { path: "/", maxAge: undefined });
new Cookie().set("refresh_token", data["refresh_token"], { path: "/", maxAge: undefined });
setStatus("Pulling your personal data...");
await readProfiles();
setStatus("Redirecting...")
setTimeout(() => navigate("/"), 1850)
}
}
callback();
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">Authenticate</h1>
<p>Via your Goatpass account</p>
</div>
<div class="pt-16 text-center">
<div class="text-center">
<div>
<span class="loading loading-lg loading-bars"></span>
</div>
<span>{status()}</span>
</div>
</div>
<Show when={error()} fallback={<div class="mt-16"></div>}>
<div id="alerts" class="mt-16">
<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>
</Show>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,55 @@
import { createSignal, Show } from "solid-js";
export default function AuthCallout() {
const [error, setError] = createSignal<string | null>(null);
const [status, setStatus] = createSignal("Communicating with Goatpass...");
async function communicate() {
const res = await fetch(`/api/auth${location.search}`);
if (res.status !== 200) {
setError(await res.text());
} else {
const data = await res.json();
setStatus("Got you! Now redirecting...");
window.open(data["target"], "_self");
}
}
communicate();
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">Authenticate</h1>
<p>Via your Goatpass account</p>
</div>
<div class="pt-16 text-center">
<div class="text-center">
<div>
<span class="loading loading-lg loading-bars"></span>
</div>
<span>{status()}</span>
</div>
</div>
<Show when={error()} fallback={<div class="mt-16"></div>}>
<div id="alerts" class="mt-16">
<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>
</Show>
</div>
</div>
</div>
);
}

View File

@ -1,5 +1,5 @@
import { getAtk, readProfiles, useUserinfo } from "../stores/userinfo.tsx";
import { createSignal, For, Show } from "solid-js";
import { useUserinfo } from "../stores/userinfo.tsx";
import { createSignal, Show } from "solid-js";
export default function DashboardPage() {
const userinfo = useUserinfo();
@ -18,19 +18,6 @@ export default function DashboardPage() {
}
}
async function readNotification(item: any) {
const res = await fetch(`/api/notifications/${item.id}/read`, {
method: "PUT",
headers: { Authorization: `Bearer ${getAtk()}` }
});
if (res.status !== 200) {
setError(await res.text());
} else {
await readProfiles();
setError(null);
}
}
return (
<div class="max-w-[720px] mx-auto px-5 pt-12">
<div id="greeting" class="px-5">
@ -39,19 +26,6 @@ export default function DashboardPage() {
</div>
<div id="alerts">
<Show when={!userinfo?.meta?.confirmed_at}>
<div role="alert" class="alert alert-warning mt-5">
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<span>Your account isn't confirmed yet. Please check your inbox and confirm your account.</span> <br />
<span>Otherwise your account will be deactivate after 48 hours.</span>
</div>
</div>
</Show>
<Show when={error()}>
<div role="alert" class="alert alert-error mt-5">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none"
@ -64,45 +38,6 @@ export default function DashboardPage() {
</Show>
</div>
<div class="card shadow-xl mt-5">
<div class="card-body">
<h2 class="card-title">Notifications</h2>
<div class="bg-base-200 mt-3 mx-[-32px]">
<Show when={userinfo?.meta?.notifications?.length <= 0}>
<table class="table">
<tbody>
<tr>
<td class="px-[32px]">You're done! There are no notifications unread for you.</td>
</tr>
</tbody>
</table>
</Show>
<Show when={userinfo?.meta?.notifications?.length > 0}>
<table class="table">
<tbody>
<For each={userinfo?.meta?.notifications}>
{item =>
<tr>
<td class="px-[32px]">
<h2 class="font-bold">{item.subject}</h2>
<p>{item.content}</p>
<div class="flex gap-2">
<Show when={item.is_important}>
<span class="font-bold">Important</span>
</Show>
<a class="link" onClick={() => readNotification(item)}>Mark as read</a>
</div>
</td>
</tr>
}
</For>
</tbody>
</table>
</Show>
</div>
</div>
</div>
</div>
);
}

View File

@ -1,95 +1,91 @@
import Cookie from "universal-cookie";
import { createContext, useContext } from "solid-js";
import { createStore } from "solid-js/store";
import {createContext, useContext} from "solid-js";
import {createStore} from "solid-js/store";
export interface Userinfo {
isLoggedIn: boolean,
displayName: string,
profiles: any,
meta: any
isLoggedIn: boolean,
displayName: string,
profiles: any,
}
const UserinfoContext = createContext<Userinfo>();
const defaultUserinfo: Userinfo = {
isLoggedIn: false,
displayName: "Citizen",
profiles: null,
meta: null
isLoggedIn: false,
displayName: "Citizen",
profiles: null,
};
const [userinfo, setUserinfo] = createStore<Userinfo>(structuredClone(defaultUserinfo));
export function getAtk(): string {
return new Cookie().get("access_token");
return new Cookie().get("access_token");
}
export async function refreshAtk() {
const rtk = new Cookie().get("refresh_token");
const rtk = new Cookie().get("refresh_token");
const res = await fetch("/api/auth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
refresh_token: rtk,
grant_type: "refresh_token"
})
});
if (res.status !== 200) {
console.error(await res.text())
} else {
const data = await res.json();
new Cookie().set("access_token", data["access_token"], { path: "/", maxAge: undefined });
new Cookie().set("refresh_token", data["refresh_token"], { path: "/", maxAge: undefined });
}
const res = await fetch("/api/auth/refresh", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
refresh_token: rtk,
})
});
if (res.status !== 200) {
console.error(await res.text())
} else {
const data = await res.json();
new Cookie().set("access_token", data["access_token"], {path: "/", maxAge: undefined});
new Cookie().set("refresh_token", data["refresh_token"], {path: "/", maxAge: undefined});
}
}
function checkLoggedIn(): boolean {
return new Cookie().get("access_token");
return new Cookie().get("access_token");
}
export async function readProfiles(recovering = true) {
if (!checkLoggedIn()) return;
if (!checkLoggedIn()) return;
const res = await fetch("/api/users/me", {
headers: { "Authorization": `Bearer ${getAtk()}` }
});
const res = await fetch("/api/users/me", {
headers: {"Authorization": `Bearer ${getAtk()}`}
});
if (res.status !== 200) {
if (recovering) {
// Auto retry after refresh access token
await refreshAtk();
return await readProfiles(false);
} else {
clearUserinfo();
window.location.reload();
if (res.status !== 200) {
if (recovering) {
// Auto retry after refresh access token
await refreshAtk();
return await readProfiles(false);
} else {
clearUserinfo();
window.location.reload();
}
}
}
const data = await res.json();
const data = await res.json();
setUserinfo({
isLoggedIn: true,
displayName: data["nick"],
profiles: null,
meta: data
});
setUserinfo({
isLoggedIn: true,
displayName: data["name"],
profiles: data,
});
}
export function clearUserinfo() {
new Cookie().remove("access_token", { path: "/", maxAge: undefined });
new Cookie().remove("refresh_token", { path: "/", maxAge: undefined });
setUserinfo(defaultUserinfo);
new Cookie().remove("access_token", {path: "/", maxAge: undefined});
new Cookie().remove("refresh_token", {path: "/", maxAge: undefined});
setUserinfo(defaultUserinfo);
}
export function UserinfoProvider(props: any) {
return (
<UserinfoContext.Provider value={userinfo}>
{props.children}
</UserinfoContext.Provider>
);
return (
<UserinfoContext.Provider value={userinfo}>
{props.children}
</UserinfoContext.Provider>
);
}
export function useUserinfo() {
return useContext(UserinfoContext);
return useContext(UserinfoContext);
}

View File

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