Skip to content

Commit 3e78692

Browse files
chore: wip
1 parent 8d5770b commit 3e78692

File tree

6 files changed

+355
-58
lines changed

6 files changed

+355
-58
lines changed

app/Models/AccessToken.ts

Lines changed: 74 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,14 @@ import { faker } from '@stacksjs/faker'
44
import { schema } from '@stacksjs/validation'
55

66
export default {
7-
name: 'AccessToken', // defaults to the sanitized file name
8-
table: 'personal_access_tokens', // defaults to the lowercase, plural name of the model name (or the name of the model file)
9-
primaryKey: 'id', // defaults to `id`
10-
autoIncrement: true, // defaults to true
11-
belongsTo: ['Team'],
7+
name: 'AccessToken',
8+
table: 'personal_access_tokens',
9+
primaryKey: 'id',
10+
autoIncrement: true,
11+
belongsTo: ['Team', 'User'], // Added User relation
1212
traits: {
13-
useTimestamps: true, // defaults to true
13+
useTimestamps: true,
1414
useSeeder: {
15-
// defaults to a count of 10
1615
count: 10,
1716
},
1817
},
@@ -27,7 +26,6 @@ export default {
2726
required: 'name is required',
2827
},
2928
},
30-
3129
factory: () => faker.lorem.sentence({ min: 3, max: 6 }),
3230
},
3331

@@ -42,7 +40,6 @@ export default {
4240
maxLength: 'token must have a maximum of 512 characters',
4341
},
4442
},
45-
4643
factory: () => faker.string.uuid(),
4744
},
4845

@@ -56,7 +53,6 @@ export default {
5653
maxLength: 'plainTextToken must have a maximum of 512 characters',
5754
},
5855
},
59-
6056
factory: () => faker.string.uuid(),
6157
},
6258

@@ -67,13 +63,78 @@ export default {
6763
message: {
6864
required: 'abilities is required',
6965
maxLength: 'plainTextToken must have a maximum of 512 characters',
70-
string:
71-
'`abilities` must be string of either `read`, `write`, `admin`, `read|write`, `read|admin`, `write|admin`, or `read|write|admin`',
66+
string: '`abilities` must be string of either `read`, `write`, `admin`, `read|write`, `read|admin`, `write|admin`, or `read|write|admin`',
7267
},
7368
},
74-
7569
factory: () =>
7670
collect(['read', 'write', 'admin', 'read|write', 'read|admin', 'write|admin', 'read|write|admin']).random().first(),
7771
},
72+
73+
// New columns
74+
lastUsedAt: {
75+
fillable: true,
76+
validation: {
77+
rule: schema.date(),
78+
message: {
79+
date: 'lastUsedAt must be a valid date',
80+
},
81+
},
82+
factory: () => faker.date.recent(),
83+
},
84+
85+
expiresAt: {
86+
fillable: true,
87+
validation: {
88+
rule: schema.date(),
89+
message: {
90+
date: 'expiresAt must be a valid date',
91+
},
92+
},
93+
factory: () => faker.date.future(),
94+
},
95+
96+
revokedAt: {
97+
fillable: true,
98+
validation: {
99+
rule: schema.date(),
100+
message: {
101+
date: 'revokedAt must be a valid date',
102+
},
103+
},
104+
factory: () => null,
105+
},
106+
107+
ipAddress: {
108+
fillable: true,
109+
validation: {
110+
rule: schema.string(),
111+
message: {
112+
string: 'ipAddress must be a string',
113+
},
114+
},
115+
factory: () => faker.internet.ip(),
116+
},
117+
118+
deviceName: {
119+
fillable: true,
120+
validation: {
121+
rule: schema.string().optional(),
122+
message: {
123+
string: 'deviceName must be a string',
124+
},
125+
},
126+
factory: () => `${faker.company.name()} Browser on ${faker.system.networkInterface()}`,
127+
},
128+
129+
isSingleUse: {
130+
fillable: true,
131+
validation: {
132+
rule: schema.boolean(),
133+
message: {
134+
boolean: 'isSingleUse must be a boolean',
135+
},
136+
},
137+
factory: () => false,
138+
},
78139
},
79140
} satisfies Model

storage/framework/core/auth/src/authentication.ts

Lines changed: 69 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import type { TeamModel } from '../../../orm/src/models/Team'
22
import type { UserModel } from '../../../orm/src/models/User'
3+
import { randomBytes } from 'node:crypto'
34
import { HttpError } from '@stacksjs/error-handling'
45
import { request } from '@stacksjs/router'
56
import { verifyHash } from '@stacksjs/security'
6-
import { sign, verify } from '@stacksjs/security' // Assuming this exists, if not we can use 'jsonwebtoken'
77
import AccessToken from '../../../orm/src/models/AccessToken'
88
import Team from '../../../orm/src/models/Team'
99
import User from '../../../orm/src/models/User'
@@ -14,16 +14,8 @@ interface Credentials {
1414
[key: string]: string | undefined
1515
}
1616

17-
interface TokenPayload {
18-
userId: number
19-
teamId?: number
20-
email: string
21-
exp?: number
22-
}
23-
2417
type AuthToken = `${number}:${number}:${string}`
2518

26-
const TOKEN_EXPIRY = 60 * 60 * 24 // 24 hours
2719
const authConfig = { username: 'email', password: 'password' }
2820

2921
let authUser: UserModel | null = null
@@ -45,43 +37,78 @@ export async function attempt(credentials: Credentials): Promise<boolean> {
4537
return false
4638
}
4739

48-
export async function generateAccessToken(user: UserModel): Promise<string> {
49-
const payload: TokenPayload = {
50-
userId: user.id as number,
51-
email: user.email as string,
52-
exp: Math.floor(Date.now() / 1000) + TOKEN_EXPIRY,
53-
}
40+
export async function createAccessToken(user: UserModel, teamId?: number): Promise<AuthToken> {
41+
const token = randomBytes(40).toString('hex')
5442

55-
return sign(payload, process.env.JWT_SECRET || 'your-secret-key')
56-
}
43+
const accessToken = await AccessToken.create({
44+
team_id: teamId,
45+
token,
46+
name: 'auth-token',
47+
expires_at: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30), // 30 days
48+
})
5749

58-
export async function verifyAccessToken(token: string): Promise<TokenPayload | null> {
59-
try {
60-
const payload = await verify(token, process.env.JWT_SECRET || 'your-secret-key') as TokenPayload
61-
return payload
62-
}
63-
catch (error) {
64-
return null
65-
}
50+
if (!accessToken?.id)
51+
throw new HttpError(500, 'Failed to create access token')
52+
53+
return `${accessToken.id}:${teamId || 0}:${token}`
6654
}
6755

68-
export async function login(credentials: Credentials): Promise<{ token: string } | null> {
56+
export async function login(credentials: Credentials): Promise<{ token: AuthToken } | null> {
6957
const isValid = await attempt(credentials)
7058

7159
if (!isValid || !authUser)
7260
return null
7361

74-
const token = await generateAccessToken(authUser)
62+
// Get user's primary team
63+
const teams = await authUser.userTeams()
64+
const primaryTeam = teams[0]
65+
66+
const token = await createAccessToken(authUser, primaryTeam?.id)
7567
return { token }
7668
}
7769

70+
export async function validateToken(token: string): Promise<boolean> {
71+
const parts = token.split(':')
72+
73+
if (parts.length !== 3)
74+
return false
75+
76+
const [tokenId, teamId, plainToken] = parts
77+
78+
const accessToken = await AccessToken.where('id', Number(tokenId))
79+
.where('token', plainToken)
80+
.where('team_id', Number(teamId))
81+
.first()
82+
83+
if (!accessToken)
84+
return false
85+
86+
// Check if token is expired
87+
if (accessToken.expires_at && new Date(accessToken.expires_at) < new Date())
88+
return false
89+
90+
// Update last used timestamp
91+
await AccessToken.where('id', accessToken.id).update({
92+
last_used_at: new Date(),
93+
})
94+
95+
return true
96+
}
97+
7898
export async function getUserFromToken(token: string): Promise<UserModel | null> {
79-
const payload = await verifyAccessToken(token)
99+
const parts = token.split(':')
100+
101+
if (parts.length !== 3)
102+
return null
103+
104+
const [tokenId] = parts
105+
106+
const accessToken = await AccessToken.where('id', Number(tokenId)).first()
80107

81-
if (!payload)
108+
if (!accessToken?.user_id)
82109
return null
83110

84-
return await User.find(payload.userId)
111+
return await User.find(accessToken.user_id)
85112
}
86113

87114
export async function team(): Promise<TeamModel | undefined> {
@@ -114,21 +141,22 @@ export async function team(): Promise<TeamModel | undefined> {
114141
return await Team.find(Number(accessToken?.team_id))
115142
}
116143

117-
export async function authToken(): Promise<AuthToken | undefined> {
118-
if (authUser) {
119-
const teams = await authUser.userTeams()
120-
const team = teams[0]
121-
const accessTokens = await team.teamAccessTokens()
122-
const accessToken = accessTokens[0]
123-
const tokenId = accessToken?.id
144+
export async function revokeToken(token: string): Promise<void> {
145+
const parts = token.split(':')
124146

125-
if (!accessToken)
126-
throw new HttpError(500, 'Error generating token!')
147+
if (parts.length !== 3)
148+
return
127149

128-
return `${tokenId}:${team.id}:${accessToken.token}`
129-
}
150+
const [tokenId] = parts
151+
152+
await AccessToken.where('id', Number(tokenId)).delete()
130153
}
131154

132155
export async function logout(): Promise<void> {
156+
const bearerToken = request.bearerToken()
157+
158+
if (bearerToken)
159+
await revokeToken(bearerToken)
160+
133161
authUser = null
134162
}

0 commit comments

Comments
 (0)