Skip to content

Commit 4f1368b

Browse files
committed
add authentication/authorization enforcement to all procedural endpoints
1 parent 9f422e7 commit 4f1368b

32 files changed

+552
-256
lines changed

Assets/css/Admin.css

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Assets/css/Admin.css.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Sources/HTTP/ServerResource.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ struct ServerResource:Equatable, Sendable
2222
self.hash = hash
2323
}
2424
}
25+
extension ServerResource:ExpressibleByStringLiteral
26+
{
27+
@inlinable public
28+
init(stringLiteral:String)
29+
{
30+
self.init(content: .string(stringLiteral), type: .text(.plain, charset: .utf8))
31+
}
32+
}
2533
extension ServerResource
2634
{
2735
/// Computes and populates the resource ``hash`` if it has not already been computed, and

Sources/HTTP/ServerResponse.swift

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,24 @@ import Media
33
@frozen public
44
enum ServerResponse:Equatable, Sendable
55
{
6-
case error (ServerResource)
7-
case forbidden (ServerResource)
8-
case ok (ServerResource)
9-
case multiple (ServerResource)
10-
case notFound (ServerResource)
6+
/// 200 OK.
7+
case ok (ServerResource)
8+
/// 300 Multiple Choices.
9+
case multiple (ServerResource)
10+
/// 400 Bad Request.
11+
case badRequest (ServerResource)
12+
/// 401 Unauthorized.
13+
case unauthorized (ServerResource)
14+
/// 403 Forbidden.
15+
case forbidden (ServerResource)
16+
/// 404 Not Found.
17+
case notFound (ServerResource)
18+
/// 409 Conflict.
19+
case conflict (ServerResource)
20+
/// 500 Internal Server Error.
21+
case error (ServerResource)
1122

12-
case redirect (ServerRedirect, cookies:[Cookie] = [])
23+
case redirect (ServerRedirect, cookies:[Cookie] = [])
1324
}
1425
extension ServerResponse
1526
{
@@ -26,12 +37,15 @@ extension ServerResponse
2637
{
2738
switch self
2839
{
29-
case .redirect: return 0
30-
case .error (let resource): return resource.content.size
31-
case .forbidden (let resource): return resource.content.size
32-
case .ok (let resource): return resource.content.size
33-
case .multiple (let resource): return resource.content.size
34-
case .notFound (let resource): return resource.content.size
40+
case .redirect: return 0
41+
case .ok (let resource): return resource.content.size
42+
case .multiple (let resource): return resource.content.size
43+
case .badRequest (let resource): return resource.content.size
44+
case .unauthorized (let resource): return resource.content.size
45+
case .forbidden (let resource): return resource.content.size
46+
case .notFound (let resource): return resource.content.size
47+
case .conflict (let resource): return resource.content.size
48+
case .error (let resource): return resource.content.size
3549
}
3650
}
3751
}

Sources/HTTPServer/Channels/ServerMessage.swift

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,32 @@ extension ServerMessage
2424
{
2525
switch response
2626
{
27+
case .ok(let resource):
28+
self.init(resource: resource, using: allocator, as: .ok)
29+
30+
case .multiple(let resource):
31+
self.init(resource: resource, using: allocator, as: .multipleChoices)
32+
2733
case .redirect(let redirect, cookies: let cookies):
2834
self.init(redirect: redirect, cookies: cookies)
2935

30-
case .error(let resource):
31-
self.init(resource: resource, using: allocator, as: .internalServerError)
36+
case .badRequest(let resource):
37+
self.init(resource: resource, using: allocator, as: .badRequest)
38+
39+
case .unauthorized(let resource):
40+
self.init(resource: resource, using: allocator, as: .unauthorized)
3241

3342
case .forbidden(let resource):
3443
self.init(resource: resource, using: allocator, as: .forbidden)
3544

36-
case .ok(let resource):
37-
self.init(resource: resource, using: allocator, as: .ok)
38-
39-
case .multiple(let resource):
40-
self.init(resource: resource, using: allocator, as: .multipleChoices)
41-
4245
case .notFound(let resource):
4346
self.init(resource: resource, using: allocator, as: .notFound)
47+
48+
case .conflict(let resource):
49+
self.init(resource: resource, using: allocator, as: .conflict)
50+
51+
case .error(let resource):
52+
self.init(resource: resource, using: allocator, as: .internalServerError)
4453
}
4554
}
4655

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import BSONDecoding
2+
import MongoQL
3+
4+
extension Account
5+
{
6+
@frozen public
7+
struct Cookie:Equatable, Hashable, Sendable
8+
{
9+
@usableFromInline internal
10+
let id:Account.ID
11+
@usableFromInline internal
12+
let cookie:Int64
13+
14+
@inlinable internal
15+
init(id:Account.ID, cookie:Int64)
16+
{
17+
self.id = id
18+
self.cookie = cookie
19+
}
20+
}
21+
}
22+
extension Account.Cookie:CustomStringConvertible
23+
{
24+
@inlinable public
25+
var description:String { "\(self.id):\(UInt64.init(bitPattern: self.cookie))" }
26+
}
27+
extension Account.Cookie:LosslessStringConvertible
28+
{
29+
@inlinable public
30+
init?(_ description:some StringProtocol)
31+
{
32+
if let colon:String.Index = description.firstIndex(of: ":"),
33+
let id:Account.ID = .init(description[..<colon]),
34+
let cookie:UInt64 = .init(description[description.index(after: colon)...])
35+
{
36+
self.init(id: id, cookie: .init(bitPattern: cookie))
37+
}
38+
else
39+
{
40+
return nil
41+
}
42+
}
43+
}
44+
extension Account.Cookie:BSONDocumentDecodable
45+
{
46+
public
47+
typealias CodingKey = Account.CodingKey
48+
49+
@inlinable public
50+
init(bson:BSON.DocumentDecoder<CodingKey, some RandomAccessCollection<UInt8>>) throws
51+
{
52+
self.init(id: try bson[.id].decode(), cookie: try bson[.cookie].decode())
53+
}
54+
}

Sources/UnidocDB/Accounts/Account.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ struct Account:Identifiable, Sendable
2525
}
2626
extension Account
2727
{
28+
@inlinable public static
29+
func machine(_ number:Int32 = 0) -> Self
30+
{
31+
.init(id: .machine(number), role: .machine)
32+
}
33+
2834
@inlinable public static
2935
func github(user:GitHub.User, role:Role) -> Self
3036
{
@@ -39,7 +45,7 @@ extension Account:MongoMasterCodingModel
3945
case id = "_id"
4046

4147
/// The session cookie associated with this account, if logged in. This is generated
42-
/// randomly in ``AccountDatabase.update(account:with:)``.
48+
/// randomly in ``AccountDatabase.Users.update(account:with:)``.
4349
case cookie
4450

4551
case role

Sources/UnidocDB/Accounts/AccountDatabase.Users.CookieView.swift

Lines changed: 0 additions & 23 deletions
This file was deleted.

Sources/UnidocDB/Accounts/AccountDatabase.Users.swift

Lines changed: 52 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -29,23 +29,7 @@ extension AccountDatabase.Users:DatabaseCollection
2929
extension AccountDatabase.Users
3030
{
3131
public
32-
func validate(cookie:String, with session:Mongo.Session) async throws -> Account.Role?
33-
{
34-
if let separator:String.Index = cookie.firstIndex(of: ":"),
35-
let account:Account.ID = .init(cookie[..<separator]),
36-
let cookie:UInt64 = .init(cookie[cookie.index(after: separator)...])
37-
{
38-
let cookie:Int64 = .init(bitPattern: cookie)
39-
return try await self.validate(cookie: cookie, from: account, with: session)
40-
}
41-
else
42-
{
43-
return nil
44-
}
45-
}
46-
private
47-
func validate(cookie:Int64,
48-
from account:Account.ID,
32+
func validate(cookie credential:Account.Cookie,
4933
with session:Mongo.Session) async throws -> Account.Role?
5034
{
5135
let matches:[RoleView] = try await session.run(
@@ -57,8 +41,8 @@ extension AccountDatabase.Users
5741
}
5842
$0[.filter] = .init
5943
{
60-
$0[Account[.id]] = account
61-
$0[Account[.cookie]] = cookie
44+
$0[Account[.id]] = credential.id
45+
$0[Account[.cookie]] = credential.cookie
6246
}
6347
$0[.projection] = .init
6448
{
@@ -78,17 +62,11 @@ extension AccountDatabase.Users
7862
/// thread while it waits for the system to generate a random number. This cookie is only
7963
/// secure if the system's random number generator is secure.
8064
public
81-
func update(account:__owned Account, with session:Mongo.Session) async throws -> String
65+
func update(account:__owned Account,
66+
with session:Mongo.Session) async throws -> Account.Cookie
8267
{
83-
let cookie:Int64 = try await self.update(account: account, with: session)
84-
return "\(account.id):\(UInt64.init(bitPattern: cookie))"
85-
}
86-
87-
private
88-
func update(account:__owned Account, with session:Mongo.Session) async throws -> Int64
89-
{
90-
let (upserted, _):(CookieView, Account.ID?) = try await session.run(
91-
command: Mongo.FindAndModify<Mongo.Upserting<CookieView, Account.ID>>.init(
68+
let (upserted, _):(Account.Cookie, Account.ID?) = try await session.run(
69+
command: Mongo.FindAndModify<Mongo.Upserting<Account.Cookie, Account.ID>>.init(
9270
Self.name,
9371
returning: .new)
9472
{
@@ -113,9 +91,53 @@ extension AccountDatabase.Users
11391
$0[Account[.cookie]] = Int64.random(in: .min ... .max)
11492
}
11593
}
94+
$0[.fields] = .init
95+
{
96+
$0[Account[.id]] = true
97+
$0[Account[.cookie]] = true
98+
}
99+
},
100+
against: self.database)
101+
102+
return upserted
103+
}
104+
}
105+
extension AccountDatabase.Users
106+
{
107+
/// Scrambles the cookie for the given account, returning the new cookie. Returns nil if
108+
/// the account does not exist.
109+
public
110+
func scramble(account:Account.ID,
111+
with session:Mongo.Session) async throws -> Account.Cookie?
112+
{
113+
let (updated, _):(Account.Cookie?, Never?) = try await session.run(
114+
command: Mongo.FindAndModify<Mongo.Existing<Account.Cookie>>.init(
115+
Self.name,
116+
returning: .new)
117+
{
118+
$0[.hint] = .init
119+
{
120+
$0[Account[.id]] = (+)
121+
}
122+
$0[.query] = .init
123+
{
124+
$0[Account[.id]] = account
125+
}
126+
$0[.update] = .init
127+
{
128+
$0[.set] = .init
129+
{
130+
$0[Account[.cookie]] = Int64.random(in: .min ... .max)
131+
}
132+
}
133+
$0[.fields] = .init
134+
{
135+
$0[Account[.id]] = true
136+
$0[Account[.cookie]] = true
137+
}
116138
},
117139
against: self.database)
118140

119-
return upserted.cookie
141+
return updated
120142
}
121143
}

Sources/UnidocPages/AdministrativePage.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ protocol AdministrativePage:StaticPage
88
}
99
extension AdministrativePage
1010
{
11+
public
12+
func head(augmenting head:inout HTML.ContentEncoder)
13+
{
14+
head[.link]
15+
{
16+
$0.href = "\(Site.Asset[.admin_css])"
17+
$0.rel = .stylesheet
18+
}
19+
}
20+
1121
public
1222
func body(_ body:inout HTML.ContentEncoder)
1323
{

0 commit comments

Comments
 (0)