feat: integrate iron session

re #131
This commit is contained in:
cedoor
2023-05-04 00:24:26 +01:00
parent 8d676d23ab
commit c7b5304304
32 changed files with 829 additions and 1449 deletions

View File

@@ -4,8 +4,8 @@ API_URL=http://localhost:3000
DASHBOARD_URL=http://localhost:3001
ETHEREUM_NETWORK=localhost
# Jwt
JWT_SECRET_KEY="bandada_jwt_secret"
# Iron session
IRON_SESSION_PASSWORD="JJ1EnoEPyesNnpdcDVD4ujVG2XKXJLQx"
# Github Passport
GITHUB_CLIENT_ID=""

View File

@@ -12,24 +12,16 @@
"@ethersproject/hash": "^5.7.0",
"@nestjs/common": "^9.0.0",
"@nestjs/core": "^9.0.0",
"@nestjs/jwt": "^10.0.2",
"@nestjs/mapped-types": "^1.2.2",
"@nestjs/passport": "^9.0.3",
"@nestjs/platform-express": "^9.0.0",
"@nestjs/schedule": "^2.2.0",
"@nestjs/typeorm": "^9.0.1",
"@semaphore-protocol/group": "3.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-session": "^1.17.3",
"passport": "^0.6.0",
"passport-github": "^1.1.0",
"passport-jwt": "^4.0.1",
"passport-oauth2": "^1.6.1",
"passport-twitter": "^1.0.4",
"iron-session": "^6.3.1",
"pg": "^8.8.0",
"querystring": "^0.2.1",
"reflect-metadata": "^0.1.13",
@@ -43,14 +35,8 @@
"@nestjs/cli": "^9.0.0",
"@nestjs/schematics": "^9.0.0",
"@nestjs/testing": "^9.0.0",
"@types/cookie-parser": "^1.4.3",
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.7",
"@types/node": "18.11.18",
"@types/passport": "^1.0.12",
"@types/passport-github": "^1.1.7",
"@types/passport-jwt": "^3.0.8",
"@types/passport-twitter": "^1.0.37",
"ethers": "5.5.1",
"ts-node": "^10.0.0",
"typescript": "^4.7.4"

View File

@@ -0,0 +1,13 @@
import { Global, Module } from "@nestjs/common"
import { TypeOrmModule } from "@nestjs/typeorm"
import { Admin } from "./entities/admin.entity"
import { AdminService } from "./admins.service"
@Global()
@Module({
imports: [TypeOrmModule.forFeature([Admin])],
exports: [AdminService],
providers: [AdminService],
controllers: []
})
export class AdminsModule {}

View File

@@ -3,20 +3,20 @@ import { id } from "@ethersproject/hash"
import { Injectable } from "@nestjs/common"
import { InjectRepository } from "@nestjs/typeorm"
import { FindOptionsWhere, Repository } from "typeorm"
import { CreateUserDTO } from "./dto/create-user.dto"
import { User } from "./entities/user.entity"
import { CreateAdminDTO } from "./dto/create-admin.dto"
import { Admin } from "./entities/admin.entity"
@Injectable()
export class UserService {
export class AdminService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>
@InjectRepository(Admin)
private readonly adminRepository: Repository<Admin>
) {}
public async create(payload: CreateUserDTO): Promise<User> {
public async create(payload: CreateAdminDTO): Promise<Admin> {
const username = payload.username || payload.address.slice(-5)
return this.userRepository.save({
return this.adminRepository.save({
id: id(payload.id),
address: payload.address,
username,
@@ -25,8 +25,8 @@ export class UserService {
}
public async findOne(
payload: FindOptionsWhere<User> | FindOptionsWhere<User>[]
): Promise<User> {
return this.userRepository.findOneBy(payload)
payload: FindOptionsWhere<Admin> | FindOptionsWhere<Admin>[]
): Promise<Admin> {
return this.adminRepository.findOneBy(payload)
}
}

View File

@@ -1,6 +1,6 @@
import { IsOptional, IsString } from "class-validator"
export class CreateUserDTO {
export class CreateAdminDTO {
@IsString()
id: string

View File

@@ -1,11 +1,11 @@
import { Column, CreateDateColumn, Entity, PrimaryColumn } from "typeorm"
@Entity("users")
export class User {
@Entity("admins")
export class Admin {
@PrimaryColumn({ unique: true })
id: string
// Wallet address of the user when using SIWE
// Wallet address of the admin when using SIWE
@Column({ unique: true })
address: string

View File

@@ -6,7 +6,7 @@ dotenvConfig({ path: resolve(process.cwd(), ".env") })
import { Module } from "@nestjs/common"
import { TypeOrmModule } from "@nestjs/typeorm"
import { UsersModule } from "./users/users.module"
import { AdminsModule } from "./admins/admins.module"
import { AuthModule } from "./auth/auth.module"
import { GroupsModule } from "./groups/groups.module"
import { InvitesModule } from "./invites/invites.module"
@@ -16,7 +16,7 @@ type DB_TYPE = "mysql" | "sqlite" | "postgres"
@Module({
imports: [
AuthModule,
UsersModule,
AdminsModule,
InvitesModule,
GroupsModule,
TypeOrmModule.forRoot({

View File

@@ -1,5 +1,6 @@
import { Body, Controller, Delete, Post, Req, Res } from "@nestjs/common"
import { Request, Response } from "express"
import { Body, Controller, Delete, Get, Post, Req } from "@nestjs/common"
import { Request } from "express"
import { generateNonce } from "siwe"
import { AuthService } from "./auth.service"
import { SignInWithEthereumDTO } from "./dto/siwe-dto"
@@ -7,29 +8,34 @@ import { SignInWithEthereumDTO } from "./dto/siwe-dto"
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Get("nonce")
async nonce(@Req() req: Request) {
req.session.nonce = generateNonce()
await req.session.save()
return req.session.nonce
}
@Post("")
async signIn(@Body() body: SignInWithEthereumDTO, @Res() res: Response) {
const { token, user } = await this.authService.signIn({
message: body.message,
signature: body.signature
})
async signIn(@Body() body: SignInWithEthereumDTO, @Req() req: Request) {
const { admin } = await this.authService.signIn(
{
message: body.message,
signature: body.signature
},
req.session.nonce
)
res.cookie("token", token, {
httpOnly: true,
expires: new Date()
})
req.session.adminId = admin.id
res.send(user)
await req.session.save()
return admin
}
@Delete("")
logOut(@Req() _req: Request, @Res() res: Response) {
res.cookie("token", "", {
httpOnly: true,
expires: new Date()
})
// TODO: Avoid this redirect here and move to client side
res.redirect(`${process.env.DASHBOARD_URL}`)
logOut(@Req() req: Request) {
req.session.destroy()
}
}

View File

@@ -4,31 +4,25 @@ import {
Injectable,
UnauthorizedException
} from "@nestjs/common"
import { JwtService } from "@nestjs/jwt"
import { UserService } from "../users/users.service"
import { AdminService } from "../admins/admins.service"
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
private jwtService: JwtService,
private userService: UserService
) {}
constructor(private adminService: AdminService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest()
const { token } = request.cookies
if (!token) {
const req = context.switchToHttp().getRequest()
const { adminId } = req.session
if (!adminId) {
throw new UnauthorizedException()
}
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: process.env.JWT_SECRET_KEY
})
const admin = await this.adminService.findOne({ id: adminId })
const user = await this.userService.findOne({ id: payload.id })
request["user"] = user
req["admin"] = admin
} catch {
throw new UnauthorizedException()
}

View File

@@ -1,21 +1,13 @@
import { Global, Module } from "@nestjs/common"
import { JwtModule } from "@nestjs/jwt"
import { UsersModule } from "../users/users.module"
import { CookieSerializer } from "../utils"
import { AdminsModule } from "../admins/admins.module"
import { AuthController } from "./auth.controller"
import { AuthService } from "./auth.service"
@Global()
@Module({
imports: [
UsersModule,
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: "300s" }
})
],
providers: [AuthService, CookieSerializer],
imports: [AdminsModule],
providers: [AuthService],
controllers: [AuthController],
exports: [JwtModule, AuthService]
exports: [AuthService]
})
export class AuthModule {}

View File

@@ -1,11 +1,10 @@
import { Test } from "@nestjs/testing"
import { TypeOrmModule } from "@nestjs/typeorm"
import { SiweMessage } from "siwe"
import { ethers } from "ethers"
import { JwtModule } from "@nestjs/jwt"
import { SiweMessage } from "siwe"
import { Admin } from "../admins/entities/admin.entity"
import { AdminService } from "../admins/admins.service"
import { AuthService } from "./auth.service"
import { User } from "../users/entities/user.entity"
import { UserService } from "../users/users.service"
jest.mock("@bandada/utils", () => ({
__esModule: true,
@@ -28,10 +27,11 @@ const account2 = new ethers.Wallet(
const mockDashboardUrl = new URL("https://bandada.test")
function createSiweMessage(address, statement?: string) {
function createSiweMessage(address: string, statement?: string) {
const message = new SiweMessage({
domain: mockDashboardUrl.host,
address,
nonce: "1234",
statement:
statement ||
"You are using your Ethereum Wallet to sign in to Bandada.",
@@ -45,7 +45,7 @@ function createSiweMessage(address, statement?: string) {
describe("AuthService", () => {
let authService: AuthService
let userService: UserService
let adminService: AdminService
let originalApiUrl: string
@@ -57,21 +57,17 @@ describe("AuthService", () => {
type: "sqlite",
database: ":memory:",
dropSchema: true,
entities: [User],
entities: [Admin],
synchronize: true
})
}),
TypeOrmModule.forFeature([User]),
JwtModule.register({
secret: "s3cret",
signOptions: { expiresIn: "300s" }
})
TypeOrmModule.forFeature([Admin])
],
providers: [AuthService, UserService]
providers: [AuthService, AdminService]
}).compile()
authService = await module.resolve(AuthService)
userService = await module.resolve(UserService)
adminService = await module.resolve(AdminService)
// Set API_URL so auth service can validate domain
originalApiUrl = process.env.DASHBOARD_URL
@@ -85,66 +81,76 @@ describe("AuthService", () => {
})
describe("# SIWE", () => {
it("Should sign in and generate token for a new user", async () => {
const message = await createSiweMessage(account1.address)
it("Should sign in and generate token for a new admin", async () => {
const message = createSiweMessage(account1.address)
const signature = await account1.signMessage(message)
const { user, token } = await authService.signIn({
message,
signature
})
const { admin } = await authService.signIn(
{
message,
signature
},
"1234"
)
expect(user).toBeTruthy()
expect(user.address).toBe(account1.address)
expect(token).toBeTruthy()
expect(admin).toBeTruthy()
expect(admin.address).toBe(account1.address)
})
it("Should sign in and generate token for an existing user", async () => {
// Create a user directly
const user2 = await userService.create({
it("Should sign in and generate token for an existing admin", async () => {
// Create a admin directly
const admin2 = await adminService.create({
id: "account2",
address: account2.address
})
// Sign in with same address
const message = await createSiweMessage(account2.address)
const message = createSiweMessage(account2.address)
const signature = await account2.signMessage(message)
const { user, token } = await authService.signIn({
message,
signature
})
const { admin } = await authService.signIn(
{
message,
signature
},
"1234"
)
expect(user).toBeTruthy()
expect(token).toBeTruthy()
expect(user.address).toBe(user2.address)
expect(user.address).toBe(account2.address)
expect(admin).toBeTruthy()
expect(admin.address).toBe(admin2.address)
expect(admin.address).toBe(account2.address)
})
it("Should throw an error if the signature is invalid", async () => {
const message = await createSiweMessage(account1.address)
const message = createSiweMessage(account1.address)
// Sign the message with a different account
const signature = await account2.signMessage(message)
await expect(
authService.signIn({
message,
signature
})
authService.signIn(
{
message,
signature
},
"1234"
)
).rejects.toThrow()
})
it("Should throw an error if the statement is invalid", async () => {
// Use a custom message to sign
const message = await createSiweMessage(account1.address, "Sign in")
const message = createSiweMessage(account1.address, "Sign in")
const signature = await account1.signMessage(message)
await expect(
authService.signIn({
message: "invalid message",
signature
})
authService.signIn(
{
message: "invalid message",
signature
},
"1234"
)
).rejects.toThrow()
})
@@ -152,14 +158,17 @@ describe("AuthService", () => {
process.env.DASHBOARD_URL = "https://bandada2.test"
// Use a custom message to sign
const message = await createSiweMessage(account1.address)
const message = createSiweMessage(account1.address)
const signature = await account1.signMessage(message)
await expect(
authService.signIn({
message: "invalid message",
signature
})
authService.signIn(
{
message: "invalid message",
signature
},
"1234"
)
).rejects.toThrow()
})
})

View File

@@ -1,25 +1,29 @@
/* istanbul ignore file */
import { Injectable, UnauthorizedException } from "@nestjs/common"
import { JwtService } from "@nestjs/jwt"
import {
Injectable,
UnauthorizedException,
UnprocessableEntityException
} from "@nestjs/common"
import { SiweMessage } from "siwe"
import { v4 } from "uuid"
import { UserService } from "../users/users.service"
import { AdminService } from "../admins/admins.service"
import { SignInWithEthereumDTO } from "./dto/siwe-dto"
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService
) {}
async signIn(params: SignInWithEthereumDTO) {
const { message, signature } = params
constructor(private readonly adminService: AdminService) {}
async signIn(
{ message, signature }: SignInWithEthereumDTO,
expectedNonce: string
) {
const siweMessage = new SiweMessage(message)
const { address, statement, domain } = await siweMessage.validate(
signature
)
const { address, statement, domain, nonce } =
await siweMessage.validate(signature)
if (nonce !== expectedNonce) {
throw new UnprocessableEntityException("Invalid nonce.")
}
if (statement !== process.env.SIWE_STATEMENT) {
throw new UnauthorizedException(
@@ -34,21 +38,15 @@ export class AuthService {
)
}
let user = await this.userService.findOne({ address })
let admin = await this.adminService.findOne({ address })
if (!user) {
user = await this.userService.create({
if (!admin) {
admin = await this.adminService.create({
id: v4(),
address
})
}
// TODO: Use common expiration
const token = this.jwtService.sign({
id: user.id,
username: user.username
})
return { token, user }
return { admin, siweMessage }
}
}

View File

@@ -1,6 +0,0 @@
export type ServiceType = "twitter" | "github" | "reddit"
export type Payload = {
userId: string
username: string
}

View File

@@ -8,16 +8,16 @@ import {
Post,
Put,
Req,
Request,
UseGuards
} from "@nestjs/common"
import { Request } from "express"
import { AuthGuard } from "../auth/auth.guard"
import { stringifyJSON } from "../utils"
import { AddMemberDto } from "./dto/add-member.dto"
import { CreateGroupDto } from "./dto/create-group.dto"
import { UpdateGroupDto } from "./dto/update-group.dto"
import { mapGroupToResponseDTO } from "./groups.utils"
import { GroupsService } from "./groups.service"
import { AuthGuard } from "../auth/auth.guard"
import { mapGroupToResponseDTO } from "./groups.utils"
@Controller("groups")
export class GroupsController {
@@ -33,7 +33,9 @@ export class GroupsController {
@Get("admin-groups")
@UseGuards(AuthGuard)
async getGroupsByAdmin(@Req() req: Request) {
const groups = await this.groupsService.getGroupsByAdmin(req["user"].id)
const groups = await this.groupsService.getGroupsByAdmin(
req["admin"].id
)
return groups.map((g) => mapGroupToResponseDTO(g))
}
@@ -46,7 +48,7 @@ export class GroupsController {
const response: any = mapGroupToResponseDTO(
group,
req["user"].id.toString() === group.admin
req["admin"].id.toString() === group.admin
)
return response
@@ -55,10 +57,10 @@ export class GroupsController {
@Post()
@UseGuards(AuthGuard)
async createGroup(@Req() req: Request, @Body() dto: CreateGroupDto) {
const group = await this.groupsService.createGroup(dto, req["user"].id)
const group = await this.groupsService.createGroup(dto, req["admin"].id)
return mapGroupToResponseDTO(
group,
req["user"].id.toString() === group.admin
req["admin"].id.toString() === group.admin
)
}
@@ -72,12 +74,12 @@ export class GroupsController {
const group = await this.groupsService.updateGroup(
groupId,
dto,
req["user"].id
req["admin"].id
)
return mapGroupToResponseDTO(
group,
req["user"].id.toString() === group.admin
req["admin"].id.toString() === group.admin
)
}
@@ -149,6 +151,10 @@ export class GroupsController {
}
// Remove as admin
await this.groupsService.removeMember(groupId, memberId, req["user"].id)
await this.groupsService.removeMember(
groupId,
memberId,
req["admin"].id
)
}
}

View File

@@ -12,7 +12,7 @@ import {
import { InjectRepository } from "@nestjs/typeorm"
import { Group as CachedGroup } from "@semaphore-protocol/group"
import { Repository } from "typeorm"
import { v4 as uuidv4 } from "uuid"
import { v4 } from "uuid"
import { InvitesService } from "../invites/invites.service"
import { CreateGroupDto } from "./dto/create-group.dto"
import { UpdateGroupDto } from "./dto/update-group.dto"
@@ -115,7 +115,7 @@ export class GroupsService {
// Generate a new API key if it doesn't exist
if (!group.apiKey) {
group.apiKey = uuidv4()
group.apiKey = v4()
}
}

View File

@@ -26,7 +26,7 @@ export class InvitesController {
): Promise<string> {
const { code } = await this.invitesService.createInvite(
dto,
req["user"].id
req["admin"].id
)
return code

View File

@@ -1,13 +0,0 @@
import { Global, Module } from "@nestjs/common"
import { TypeOrmModule } from "@nestjs/typeorm"
import { User } from "./entities/user.entity"
import { UserService } from "./users.service"
@Global()
@Module({
imports: [TypeOrmModule.forFeature([User])],
exports: [UserService],
providers: [UserService],
controllers: []
})
export class UsersModule {}

View File

@@ -1,14 +0,0 @@
/* istanbul ignore file */
import { Injectable } from "@nestjs/common"
import { PassportSerializer } from "@nestjs/passport"
@Injectable()
export default class CookieSerializer extends PassportSerializer {
serializeUser(user, done: (a, b) => void) {
done(null, user)
}
deserializeUser(payload, done: (a, b) => void) {
done(null, payload)
}
}

View File

@@ -1,12 +1,4 @@
import mapEntity from "./mapEntity"
import stringifyJSON from "./stringifyJSON"
import CookieSerializer from "./cookie.serializer"
import RedditStrategy, { RedditProfile } from "./passportReddit"
export {
mapEntity,
stringifyJSON,
CookieSerializer,
RedditProfile,
RedditStrategy
}
export { mapEntity, stringifyJSON }

View File

@@ -1,133 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* istanbul ignore file */
import {
InternalOAuthError,
Strategy as OAuth2Strategy,
StrategyOptions,
VerifyFunction
} from "passport-oauth2"
import { stringify } from "querystring"
export type RedditProfile = {
id: string
name: string
icon_img: string
subreddit: {
title: string
}
}
export default class Strategy extends OAuth2Strategy {
public name = "reddit"
private _userProfileURL = "https://oauth.reddit.com/api/v1/me"
constructor(redditOptions: any, verify: VerifyFunction) {
const options: StrategyOptions = {
authorizationURL: "https://ssl.reddit.com/api/v1/authorize",
tokenURL: "https://ssl.reddit.com/api/v1/access_token",
...redditOptions
}
if (options.scope) {
if (Array.isArray(options.scope)) {
options.scope.push("identity")
options.scopeSeparator = ","
} else {
options.scope = options.scope
.split(",")
.reduce(
(previousValue, currentValue) => {
if (currentValue !== "")
previousValue.push(currentValue)
return previousValue
},
["identity"]
)
.join(",")
}
} else {
options.scope = "identity"
}
if (
typeof options.state === "undefined" &&
typeof options.store === "undefined"
) {
options.state = true
}
super(options, verify)
this._oauth2.useAuthorizationHeaderforGET(true)
this._oauth2.getOAuthAccessToken = function getOAuthAccessToken(
code: string,
params: any,
callback: any
) {
params = params || {}
params.type = "web_server"
const codeParam =
params.grant_type === "refresh_token" ? "refresh_token" : "code"
params[codeParam] = code
const post_data = stringify(params)
const authorization = `Basic ${Buffer.from(
`${this._clientId}:${this._clientSecret}`,
"utf8"
).toString("base64")}`
const post_headers = {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: authorization
}
this._request(
"POST",
this._getAccessTokenUrl(),
post_headers,
post_data,
null,
(error: any, data: any) => {
if (error) {
callback(error)
} else {
const results = JSON.parse(data)
const { access_token } = results
const { refresh_token } = results
delete results.refresh_token
callback(null, access_token, refresh_token, results)
}
}
)
} as any
}
userProfile(
accessToken: string,
done: (err?: Error | null, profile?: any) => void
) {
this._oauth2.get(
this._userProfileURL,
accessToken,
(err: { statusCode: number; data?: any }, result: string) => {
if (err) {
return done(
new InternalOAuthError(
"Failed to fetch user profile",
err
)
)
}
try {
done(null, JSON.parse(result))
} catch (error) {
done(error)
}
}
)
}
}

View File

@@ -1,7 +1,6 @@
import { Logger, ValidationPipe } from "@nestjs/common"
import { NestFactory } from "@nestjs/core"
import * as cookieParser from "cookie-parser"
import * as session from "express-session"
import { ironSession } from "iron-session/express"
import { AppModule } from "./app/app.module"
async function bootstrap() {
@@ -18,14 +17,17 @@ async function bootstrap() {
const port = 3000
app.use(
session({
secret: process.env.JWT_SECRET_KEY,
resave: false,
saveUninitialized: false
ironSession({
cookieName: "bandada_siwe_cookie",
password: process.env.IRON_SESSION_PASSWORD,
cookieOptions: {
// TODO: decide when session should expiry.
// expires: ,
secure: process.env.NODE_ENV === "production"
}
})
)
app.use(cookieParser())
app.enableCors({
origin: true,
credentials: true

View File

@@ -0,0 +1,9 @@
import "iron-session"
import { SiweMessage } from "siwe"
declare module "iron-session" {
interface IronSessionData {
nonce?: string
adminId?: string
}
}

View File

@@ -23,7 +23,7 @@
"react-icons": "^4.8.0",
"react-router-dom": "^6.8.1",
"siwe": "^1.1.6",
"wagmi": "^0.11.7"
"wagmi": "^0.12.12"
},
"devDependencies": {
"@types/react": "^18.0.27",

View File

@@ -1,6 +1,6 @@
import { request } from "@bandada/utils"
import { Group } from "../types/groups"
import { SiweMessage } from "siwe"
import { Group } from "../types/groups"
const API_URL = import.meta.env.VITE_API_URL
const CLIENT_URL = import.meta.env.VITE_CLIENT_URL
@@ -73,58 +73,62 @@ export async function removeMember(groupId: string, memberId: string) {
}
}
export async function logOut(): Promise<void | null> {
try {
// TODO: check if this works properly.
await request(`${API_URL}/auth/log-out`, {
method: "post"
})
} catch (error) {
console.error(error)
return null
}
}
export async function isLoggedIn(): Promise<boolean> {
try {
return await request(`${import.meta.env.VITE_API_URL}/auth/getUser`)
} catch (error) {
return false
}
}
export async function updateGroup(
groupId: string,
{ apiEnabled }: { apiEnabled: boolean }
) {
try {
const group = await request(`${API_URL}/groups/${groupId}`, {
return (await request(`${API_URL}/groups/${groupId}`, {
method: "PUT",
data: { apiEnabled }
})
return group as Group
})) as Group
} catch (error) {
console.error(error)
}
}
export async function getNonce(): Promise<string | null> {
try {
return await request(`${API_URL}/auth/nonce`, {
method: "GET"
})
} catch (error) {
console.error(error)
return null
}
}
export async function signIn({
message,
signature
}: {
message: SiweMessage
signature: string
}) {
const response = await request(`${API_URL}/auth`, {
method: "POST",
data: {
message,
signature
}
})
}): Promise<any | null> {
try {
return await request(`${API_URL}/auth`, {
method: "POST",
data: {
message: message.toMessage(),
signature
}
})
} catch (error) {
console.error(error)
const { user } = response.data
return { user }
return null
}
}
export async function logOut(): Promise<void | null> {
try {
await request(`${API_URL}/auth`, {
method: "DELETE"
})
} catch (error) {
console.error(error)
return null
}
}

View File

@@ -16,6 +16,7 @@ import { useCallback } from "react"
import { useNavigate, useSearchParams } from "react-router-dom"
import { useAccount, useConnect } from "wagmi"
import { logOut as _logOut } from "../api/bandadaAPI"
import { deleteAdmin } from "../utils/session"
export default function NavBar(): JSX.Element {
const navigate = useNavigate()
@@ -28,6 +29,8 @@ export default function NavBar(): JSX.Element {
const logOut = useCallback(async () => {
await _logOut()
deleteAdmin()
navigate("/")
}, [navigate])

View File

@@ -1,29 +1,27 @@
import {
connectorsForWallets,
lightTheme,
AuthenticationStatus,
connectorsForWallets,
createAuthenticationAdapter,
lightTheme,
RainbowKitAuthenticationProvider,
RainbowKitProvider
} from "@rainbow-me/rainbowkit"
import React, { ReactNode } from "react"
import {
metaMaskWallet,
coinbaseWallet,
walletConnectWallet,
trustWallet,
ledgerWallet
injectedWallet,
metaMaskWallet,
walletConnectWallet
} from "@rainbow-me/rainbowkit/wallets"
import { configureChains, createClient, WagmiConfig } from "wagmi"
import { mainnet } from "wagmi/chains"
import { publicProvider } from "wagmi/providers/public"
import React, { ReactNode, useMemo, useState } from "react"
import { SiweMessage } from "siwe"
import { signIn } from "../api/bandadaAPI"
import { deleteUser, getUser, saveUser } from "../utils/auth"
import { User } from "../types/user"
import { configureChains, createClient, WagmiConfig } from "wagmi"
import { goerli } from "wagmi/chains"
import { publicProvider } from "wagmi/providers/public"
import { getNonce, logOut, signIn } from "../api/bandadaAPI"
import { deleteAdmin, getAdmin, saveAdmin } from "../utils/session"
const { chains, provider, webSocketProvider } = configureChains(
[mainnet],
[goerli],
[publicProvider()]
)
@@ -31,11 +29,10 @@ const connectors = connectorsForWallets([
{
groupName: "Wallets",
wallets: [
injectedWallet({ chains }),
metaMaskWallet({ chains }),
coinbaseWallet({ appName: "Bandada", chains }),
walletConnectWallet({ chains }),
trustWallet({ chains }),
ledgerWallet({ chains })
walletConnectWallet({ chains })
]
}
])
@@ -47,42 +44,34 @@ const wagmiClient = createClient({
webSocketProvider
})
type AuthContextParams = {
user: User | null
}
export const AuthContext = React.createContext<AuthContextParams>({
user: getUser()
})
const customTheme = lightTheme()
customTheme.radii.modal = "10px"
export function AuthContextProvider(props: { children: ReactNode }) {
const { children } = props
const verifyingRef = React.useRef(false)
const [user, setUser] = React.useState<User | null>(getUser())
const [authStatus, setAuthStatus] =
React.useState<AuthenticationStatus>("loading")
useState<AuthenticationStatus>("loading")
React.useEffect(() => {
setAuthStatus(getUser() ? "authenticated" : "unauthenticated")
setAuthStatus(getAdmin() ? "authenticated" : "unauthenticated")
}, [])
const authAdapter = React.useMemo(
const authAdapter = useMemo(
() =>
createAuthenticationAdapter({
async getNonce() {
return Math.random().toString(36).substring(7)
const nonce = await getNonce()
return nonce ?? ""
},
createMessage: ({ nonce, address, chainId }) =>
new SiweMessage({
domain: window.location.host,
address,
statement: "Sign in with Ethereum to the app.",
statement:
"You are using your Ethereum Wallet to sign in to Bandada.",
uri: window.location.origin,
version: "1",
chainId,
@@ -92,62 +81,54 @@ export function AuthContextProvider(props: { children: ReactNode }) {
getMessageBody: ({ message }) => message.prepareMessage(),
verify: async ({ message, signature }) => {
verifyingRef.current = true
const user = await signIn({
message,
signature
})
try {
const { user: _user } = await signIn({
message,
signature
})
if (user) {
setAuthStatus("authenticated")
saveAdmin(user.address)
saveUser(_user)
setAuthStatus(
_user ? "authenticated" : "unauthenticated"
)
setUser(_user)
window.location.reload()
return true
} catch (error) {
console.error(error)
return false
} finally {
verifyingRef.current = false
}
setAuthStatus("unauthenticated")
return false
},
signOut: async () => {
await logOut()
deleteAdmin()
setAuthStatus("unauthenticated")
deleteUser()
}
}),
[]
)
return (
<AuthContext.Provider
value={{
user
}}
>
<WagmiConfig client={wagmiClient}>
<RainbowKitAuthenticationProvider
adapter={authAdapter}
status={authStatus}
<WagmiConfig client={wagmiClient}>
<RainbowKitAuthenticationProvider
adapter={authAdapter}
status={authStatus}
>
<RainbowKitProvider
chains={chains}
modalSize="compact"
theme={customTheme}
appInfo={{
appName: "Bandada"
}}
showRecentTransactions={false}
>
<RainbowKitProvider
chains={chains}
modalSize="compact"
theme={customTheme}
appInfo={{
appName: "Bandada"
}}
showRecentTransactions={false}
>
{children}
</RainbowKitProvider>
</RainbowKitAuthenticationProvider>
</WagmiConfig>
</AuthContext.Provider>
{children}
</RainbowKitProvider>
</RainbowKitAuthenticationProvider>
</WagmiConfig>
)
}

View File

@@ -4,14 +4,14 @@ import "@rainbow-me/rainbowkit/styles.css"
import { StrictMode } from "react"
import * as ReactDOM from "react-dom/client"
import { createBrowserRouter, redirect, RouterProvider } from "react-router-dom"
import { isLoggedIn } from "./api/bandadaAPI"
import { AuthContextProvider } from "./context/auth-context"
import NotFoundPage from "./pages/404"
import Home from "./pages/home"
import Manage from "./pages/manage"
import MyGroups from "./pages/my-groups"
import SSO from "./pages/siwe"
import SIWE from "./pages/siwe"
import theme from "./styles"
import { AuthContextProvider } from "./context/auth-context"
import { getAdmin } from "./utils/session"
const router = createBrowserRouter([
{
@@ -20,14 +20,15 @@ const router = createBrowserRouter([
async loader({ request }) {
const { pathname } = new URL(request.url)
const _loggedIn = await isLoggedIn()
if (["/login", "/sign-up"].includes(pathname) && getAdmin()) {
throw redirect("/my-groups")
}
if (["/login", "/sign-up"].includes(pathname)) {
if (_loggedIn) {
throw redirect("/my-groups")
}
} else if (!_loggedIn) {
throw redirect("/login")
if (
!["/", "/login", "/sign-up"].includes(pathname) &&
!getAdmin()
) {
throw redirect("/")
}
return null
@@ -35,11 +36,11 @@ const router = createBrowserRouter([
children: [
{
path: "login",
element: <SSO />
element: <SIWE />
},
{
path: "sign-up",
element: <SSO />
element: <SIWE />
},
{
path: "my-groups",

View File

@@ -9,27 +9,14 @@ import {
Text,
VStack
} from "@chakra-ui/react"
import { useEffect } from "react"
import { FaEthereum } from "react-icons/fa"
import { useLocation, useNavigate } from "react-router-dom"
import { goerli, useAccount, useConnect, useSwitchNetwork } from "wagmi"
import logoUrl from "../assets/logo.svg"
import { useConnectModal } from "@rainbow-me/rainbowkit"
import { FaEthereum } from "react-icons/fa"
import { useLocation } from "react-router-dom"
import logoUrl from "../assets/logo.svg"
export default function SSO(): JSX.Element {
const navigate = useNavigate()
export default function SIWE(): JSX.Element {
const { openConnectModal } = useConnectModal()
const { switchNetwork } = useSwitchNetwork()
const { pathname } = useLocation()
const { isConnected } = useAccount()
useEffect(() => {
if (isConnected) {
switchNetwork?.(goerli.id)
navigate("/my-groups?on-chain")
}
}, [isConnected, navigate, switchNetwork])
return (
<Container maxW="container.xl" pt="20" pb="20" px="6">
@@ -63,9 +50,7 @@ export default function SSO(): JSX.Element {
border="1px solid #D0D1D2"
fontSize="18px"
w="500px"
onClick={() =>
openConnectModal()
}
onClick={openConnectModal}
>
<Icon as={FaEthereum} mr="13px" />
Connect Wallet

View File

@@ -1,5 +0,0 @@
export type User = {
id: string;
address: string;
username: string;
}

View File

@@ -1,19 +0,0 @@
import { User } from "../types/user";
export function saveUser(user: User) {
// Update token in storage
window.localStorage.setItem('user', JSON.stringify(user));
}
export function getUser() {
const user = window.localStorage.getItem('user');
if (user) {
return JSON.parse(user);
}
return null;
}
export function deleteUser() {
window.localStorage.removeItem('user');
}

View File

@@ -0,0 +1,16 @@
// iron-session handles sessions with encoded cookies.
// Since browsers cannot access it, it is necessary
// to store admins' sessions manually.
// TODO: explore other solutions.
export function saveAdmin(address: string) {
localStorage.setItem("admin", address)
}
export function getAdmin() {
return localStorage.getItem("admin")
}
export function deleteAdmin() {
localStorage.removeItem("admin")
}

1441
yarn.lock

File diff suppressed because it is too large Load Diff