Skip to content

Commit

Permalink
Merge pull request #690 from the-clothing-loop/token-stateless
Browse files Browse the repository at this point in the history
Token stateless
  • Loading branch information
lil5 authored Feb 22, 2024
2 parents 308457d + 1f9cd5a commit ba67b20
Show file tree
Hide file tree
Showing 24 changed files with 1,145 additions and 281 deletions.
59 changes: 51 additions & 8 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { ToastProvider } from "./providers/ToastProvider";
import { useTranslation } from "react-i18next";
import { t } from "i18next";
import Contribute from "./pages/Contribute";
import { Helmet } from "react-helmet";

// Lazy
const FindChain = React.lazy(() => import("./pages/FindChain"));
Expand Down Expand Up @@ -232,6 +233,11 @@ export default function App() {
path={`${base}/admin/dashboard`}
component={AdminDashboard}
/>
<Route
exact
path={`${base}/500`}
component={InternalServerError}
/>
<Route path={`${base}/*`} component={FileNotFound} />
<Route path="*" component={I18nRedirect} />
</Switch>
Expand Down Expand Up @@ -263,13 +269,50 @@ function I18nRedirect() {

function FileNotFound() {
return (
<div className="max-w-screen-sm mx-auto flex-grow flex flex-col justify-center items-center">
<h1 className="font-serif text-secondary text-4xl font-bold my-10">
404 File not found
</h1>
<Link to="/" className="btn btn-primary">
{t("home")}
</Link>
</div>
<>
<Helmet>
<title>The Clothing Loop | 404</title>
<meta name="description" content="File not found" />
</Helmet>
<div className="max-w-screen-sm mx-auto flex-grow flex flex-col justify-center items-center">
<h1 className="font-serif text-secondary text-4xl font-bold my-10">
404 File not found
</h1>
<Link to="/" className="btn btn-primary">
{t("home")}
</Link>
</div>
</>
);
}

function InternalServerError() {
const l = useLocation<{ err?: any }>();

let err = l.state?.err;
if (err !== undefined && typeof err !== "string") {
if (typeof err?.message === "string") {
err = err.message;
}
if (typeof err?.data === "string") {
err = err.data;
}
}
return (
<>
<Helmet>
<title>The Clothing Loop | 500</title>
<meta name="description" content="Internal server error" />
</Helmet>
<div className="max-w-screen-sm mx-auto flex-grow flex flex-col justify-center items-center">
<h1 className="font-serif text-secondary text-4xl font-bold my-10">
500 Internal server error
</h1>
{err ? <p className="-mt-6 mb-10">{err}</p> : null}
<Link to="/" className="btn btn-primary">
{t("home")}
</Link>
</div>
</>
);
}
8 changes: 3 additions & 5 deletions frontend/src/api/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,12 @@ export function loginEmailAndAddToChain(email: string, chainUID: UID) {
});
}

export function loginValidate(key: string, chainUID: UID) {
let params: Record<string, string> = {
apiKey: key,
};
export function loginValidate(u: string, apiKey: string, chainUID: UID) {
let params: Record<string, string> = { u, apiKey };
if (chainUID) {
params["c"] = chainUID;
}
return window.axios.get<{ user: User }>(`/v2/login/validate?apiKey=${key}`, {
return window.axios.get<{ user: User }>(`/v2/login/validate`, {
params,
});
}
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/pages/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export default function Login() {
setActive(true);

(async () => {
let apiKey: string | undefined;
let otp: string | undefined;
try {
let res: Response<unknown>;
if (chainUID) {
Expand All @@ -59,7 +59,7 @@ export default function Login() {
}

if (res.data && (res.data + "").length) {
apiKey = res.data + "";
otp = res.data + "";
} else {
addToast({
type: "success",
Expand All @@ -78,8 +78,10 @@ export default function Login() {
addToastError(GinParseErrors(t, err), err?.status);
}

if (apiKey) {
history.replace("/users/login/validate?apiKey=" + apiKey);
if (otp) {
let emailBase64 = btoa(email);

history.replace(`/users/login/validate?u=${emailBase64}&apiKey=${otp}`);
}
})();
}
Expand Down
14 changes: 9 additions & 5 deletions frontend/src/pages/LoginEmailFinished.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ export default function LoginEmailFinished() {

const search = useLocation().search;
const query = new URLSearchParams(search);
const apiKey = query.get("apiKey");
const otp = query.get("apiKey");
const emailBase64 = query.get("u");
const chainUID = query.get("c") || "";
const { authLoginValidate } = useContext(AuthContext);
const { addToast, addToastError } = useContext(ToastContext);
Expand All @@ -24,10 +25,13 @@ export default function LoginEmailFinished() {
(async () => {
let user: User | undefined;
try {
if (!apiKey) {
throw "apiKey does not exist";
if (!otp) {
throw "One time password does not exist";
}
user = await authLoginValidate(apiKey!, chainUID);
if (!emailBase64) {
throw "Email is not included in request";
}
user = await authLoginValidate(emailBase64, otp!, chainUID);
addToast({
message: t("userIsLoggedIn"),
type: "success",
Expand All @@ -48,7 +52,7 @@ export default function LoginEmailFinished() {
history.replace("/");
}
})();
}, [apiKey]);
}, [otp]);

return (
<>
Expand Down
25 changes: 17 additions & 8 deletions frontend/src/providers/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import { User } from "../api/types";
import { userGetByUID, userUpdate } from "../api/user";
import Cookies from "js-cookie";
import i18n from "../i18n";
import { Sleep } from "../util/sleep";

const IS_DEV_MODE = import.meta.env.DEV;

export enum UserRefreshState {
NeverLoggedIn,
LoggedIn,
ForceLoggedOut,
Offline,
}

export type AuthProps = {
Expand All @@ -26,7 +28,8 @@ export type AuthProps = {
// ? Should loading only be used for authentication or also for login & logout?
loading: boolean;
authLoginValidate: (
apiKey: string,
emailBase64: string,
otp: string,
chainUID: string
) => Promise<undefined | User>;
authLogout: () => Promise<void>;
Expand All @@ -36,7 +39,7 @@ export type AuthProps = {
export const AuthContext = createContext<AuthProps>({
authUser: undefined,
loading: true,
authLoginValidate: (apiKey, c) => Promise.reject(),
authLoginValidate: (u, t, c) => Promise.reject(),
authLogout: () => Promise.reject(),
authUserRefresh: () => Promise.reject(UserRefreshState.NeverLoggedIn),
});
Expand All @@ -51,12 +54,16 @@ const cookieOptions: Cookies.CookieAttributes = {
export function AuthProvider({ children }: PropsWithChildren<{}>) {
const [user, setUser] = useState<AuthProps["authUser"]>(undefined);
const [loading, setLoading] = useState(true);
function authLoginValidate(apiKey: string, chainUID: string) {
function authLoginValidate(
emailBase64: string,
otp: string,
chainUID: string
) {
setLoading(true);
return (async () => {
let _user: User | null | undefined = undefined;
try {
_user = (await apiLogin(apiKey, chainUID)).data.user;
_user = (await apiLogin(emailBase64, otp, chainUID)).data.user;
Cookies.set(KEY_USER_UID, _user.uid, cookieOptions);
} catch (err) {
setUser(null);
Expand Down Expand Up @@ -108,10 +115,12 @@ export function AuthProvider({ children }: PropsWithChildren<{}>) {
i18n: i18n.language,
});
}
} catch (err) {
await authLogout().catch((err) => {
console.error("force logout failed:", err);
});
} catch (err: any) {
if (err?.status === 401) {
await authLogout().catch((err) => {
console.error("force logout failed:", err);
});
}
console.info("force logout");
return UserRefreshState.ForceLoggedOut;
}
Expand Down
2 changes: 2 additions & 0 deletions server/config.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ cookie_https_only: false
stripe_secret_key: "secret"
stripe_webhook: "secret"

jwt_secret: "secret"

db_host: "db"
db_port: 3306
db_name: "clothingloop"
Expand Down
1 change: 1 addition & 0 deletions server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/gin-gonic/gin v1.9.1
github.com/go-co-op/gocron v1.29.0
github.com/go-playground/validator/v10 v10.14.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/golang/glog v1.1.1
github.com/jaswdr/faker v1.18.0
github.com/lil5/goscope2 v1.4.2
Expand Down
2 changes: 2 additions & 0 deletions server/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrt
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/glog v1.1.1 h1:jxpi2eWoU84wbX9iIEyAeeoac3FLuifZpY9tcNUD9kw=
github.com/golang/glog v1.1.1/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ=
Expand Down
47 changes: 29 additions & 18 deletions server/internal/app/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,23 @@ func Authenticate(c *gin.Context, db *gorm.DB, minimumAuthState int, chainUID st
return false, nil, nil
}

authUser, ok = TokenAuthenticate(db, token)
if !ok {
var err error
var usedOldToken bool
authUser, usedOldToken, err = AuthenticateToken(db, token)
if err != nil {
c.String(http.StatusUnauthorized, "Invalid token")
return false, nil, nil
}

if usedOldToken {
token, err := JwtGenerate(authUser)
if err != nil {
c.String(http.StatusUnauthorized, "Invalid token")
return false, nil, nil
}
CookieSet(c, token)
}

// 1. User of a different/unknown chain
if minimumAuthState == AuthState1AnyUser && chainUID == "" {
return true, authUser, nil
Expand All @@ -54,7 +65,7 @@ func Authenticate(c *gin.Context, db *gorm.DB, minimumAuthState int, chainUID st
}

chain = &models.Chain{}
err := db.Raw(`SELECT * FROM chains WHERE chains.uid = ? AND chains.deleted_at IS NULL LIMIT 1`, chainUID).Scan(chain).Error
err = db.Raw(`SELECT * FROM chains WHERE chains.uid = ? AND chains.deleted_at IS NULL LIMIT 1`, chainUID).Scan(chain).Error
if err != nil {
c.String(http.StatusBadRequest, models.ErrChainNotFound.Error())
return false, nil, nil
Expand All @@ -68,34 +79,34 @@ func Authenticate(c *gin.Context, db *gorm.DB, minimumAuthState int, chainUID st
}

// 4. Root User
if authUser.IsRootAdmin {
return true, authUser, chain
}
isRootUser := authUser.IsRootAdmin

// 1. User of a different/unknown chain
if minimumAuthState == AuthState1AnyUser {
return true, authUser, chain
}
isUserOfUnknownChain := minimumAuthState == AuthState1AnyUser

isUserParticipantOfChain := false
isUserAdminOfChain := false
if minimumAuthState == AuthState2UserOfChain || minimumAuthState == AuthState3AdminChainUser {
for _, userChain := range authUser.Chains {
if userChain.ChainID == chain.ID {
// 2. User connected to the chain in question
if minimumAuthState == AuthState2UserOfChain {
return true, authUser, chain
}

// 3. Admin User of the chain in question
if minimumAuthState == AuthState3AdminChainUser && userChain.IsChainAdmin {
return true, authUser, chain
// 2. User connected to the chain in question
isUserParticipantOfChain = true
} else if minimumAuthState == AuthState3AdminChainUser && userChain.IsChainAdmin {
// 3. Admin User of the chain in question
isUserAdminOfChain = true
}
break
}
}
}

c.String(http.StatusUnauthorized, "User role not high enough")
return false, nil, nil
if !(isRootUser || isUserOfUnknownChain || isUserParticipantOfChain || isUserAdminOfChain) {
c.String(http.StatusUnauthorized, "User role not high enough")
return false, nil, nil
}

return true, authUser, chain
}

// This runs Authenticate and defines minimumAuthState depending on the input
Expand Down
Loading

0 comments on commit ba67b20

Please sign in to comment.