Skip to content

Commit 5f31901

Browse files
committed
generate session cookies and secure the administrator dashboard for real
1 parent a7b757d commit 5f31901

34 files changed

+511
-236
lines changed

Sources/HTTPServer/Responses/ServerMessage.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ extension ServerMessage
5454
case .error:
5555
status = .internalServerError
5656

57+
case .forbidden:
58+
status = .forbidden
59+
5760
case .none:
5861
status = .notFound
5962

Sources/HTTPServer/Responses/ServerResource.Content.swift

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,22 @@ extension ServerResource
1313
}
1414
extension ServerResource.Content
1515
{
16+
@inlinable public
17+
var length:Int
18+
{
19+
switch self
20+
{
21+
case .binary(let buffer): return buffer.count
22+
case .buffer(let buffer): return buffer.readableBytes
23+
case .string(let string): return string.utf8.count
24+
case .length(let length): return length
25+
}
26+
}
1627
/// Drops any payload storage held by this instance, and replaces it with the length of the
1728
/// dropped payload. If the payload is already a ``length(_:)``, this function does nothing.
1829
@inlinable public mutating
1930
func drop()
2031
{
21-
switch self
22-
{
23-
case .binary(let buffer): self = .length(buffer.count)
24-
case .buffer(let buffer): self = .length(buffer.readableBytes)
25-
case .string(let string): self = .length(string.utf8.count)
26-
case .length: return
27-
}
32+
self = .length(self.length)
2833
}
2934
}

Sources/HTTPServer/Responses/ServerResource.Results.swift

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ extension ServerResource
55
enum Results:Equatable, Hashable, Sendable
66
{
77
case error
8+
9+
case forbidden
10+
811
case many
912
case none
1013
case one(canonical:String?)
@@ -17,7 +20,7 @@ extension ServerResource.Results
1720
{
1821
switch self
1922
{
20-
case .error, .many, .none: return nil
23+
case .error, .forbidden, .many, .none: return nil
2124
case .one(canonical: let canonical): return canonical
2225
}
2326
}

Sources/HTTPServer/Responses/ServerResponse.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,23 @@ enum ServerResponse:Equatable, Sendable
66
case redirect(ServerRedirect, cookies:[Cookie] = [])
77
case resource(ServerResource)
88
}
9+
extension ServerResponse
10+
{
11+
@inlinable public static
12+
func redirect(_ redirect:ServerRedirect, cookies:KeyValuePairs<String, String>) -> Self
13+
{
14+
.redirect(redirect, cookies: cookies.map(Cookie.init(name:value:)))
15+
}
16+
}
17+
extension ServerResponse
18+
{
19+
@inlinable public
20+
var resource:ServerResource?
21+
{
22+
switch self
23+
{
24+
case .redirect: return nil
25+
case .resource(let resource): return resource
26+
}
27+
}
28+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import BSONDecoding
2+
3+
extension Account.Database.Users
4+
{
5+
struct CookieView:Equatable, Sendable
6+
{
7+
let cookie:Int64
8+
9+
init(cookie:Int64)
10+
{
11+
self.cookie = cookie
12+
}
13+
}
14+
}
15+
extension Account.Database.Users.CookieView:BSONDocumentDecodable
16+
{
17+
typealias CodingKey = Account.CodingKey
18+
19+
init(bson:BSON.DocumentDecoder<CodingKey, some RandomAccessCollection<UInt8>>) throws
20+
{
21+
self.init(cookie: try bson[.cookie].decode())
22+
}
23+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import BSONDecoding
2+
3+
extension Account.Database.Users
4+
{
5+
struct RoleView:Equatable, Sendable
6+
{
7+
let role:Account.Role
8+
9+
init(role:Account.Role)
10+
{
11+
self.role = role
12+
}
13+
}
14+
}
15+
extension Account.Database.Users.RoleView:BSONDocumentDecodable
16+
{
17+
typealias CodingKey = Account.CodingKey
18+
19+
init(bson:BSON.DocumentDecoder<CodingKey, some RandomAccessCollection<UInt8>>) throws
20+
{
21+
self.init(role: try bson[.role].decode())
22+
}
23+
}
Lines changed: 96 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import MongoDB
12
import MongoQL
23

34
extension Account.Database
45
{
5-
public
6+
@frozen public
67
struct Users
78
{
9+
public
810
let database:Mongo.Database
911

12+
@inlinable public
1013
init(database:Mongo.Database)
1114
{
1215
self.database = database
@@ -21,17 +24,98 @@ extension Account.Database.Users:DatabaseCollection
2124
typealias ElementID = Account.ID
2225

2326
static
24-
let indexes:[Mongo.CreateIndexStatement] =
25-
[
26-
.init
27+
let indexes:[Mongo.CreateIndexStatement] = []
28+
}
29+
extension Account.Database.Users
30+
{
31+
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)...])
2737
{
28-
$0[.unique] = true
29-
$0[.name] = "session,role"
30-
$0[.key] = .init
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,
49+
with session:Mongo.Session) async throws -> Account.Role?
50+
{
51+
let matches:[RoleView] = try await session.run(
52+
command: Mongo.Find<Mongo.SingleBatch<RoleView>>.init(Self.name, limit: 1)
3153
{
32-
$0[Account[.session]] = (+)
33-
$0[Account[.role]] = (+)
34-
}
35-
},
36-
]
54+
$0[.hint] = .init
55+
{
56+
$0[Account[.id]] = (+)
57+
}
58+
$0[.filter] = .init
59+
{
60+
$0[Account[.id]] = account
61+
$0[Account[.cookie]] = cookie
62+
}
63+
$0[.projection] = .init
64+
{
65+
$0[Account[.role]] = true
66+
}
67+
},
68+
against: self.database)
69+
70+
return matches.first?.role
71+
}
72+
73+
/// Upserts the given account into the database, returning a new, randomly-generated
74+
/// session cookie if the account was inserted, or the existing cookie if the account
75+
/// already exists.
76+
///
77+
/// This function always calls into ``Int64.random(in:)``, which might block the current
78+
/// thread while it waits for the system to generate a random number. This cookie is only
79+
/// secure if the system's random number generator is secure.
80+
public
81+
func update(account:__owned Account, with session:Mongo.Session) async throws -> String
82+
{
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(
92+
Self.name,
93+
returning: .new)
94+
{
95+
$0[.hint] = .init
96+
{
97+
$0[Account[.id]] = (+)
98+
}
99+
$0[.query] = .init
100+
{
101+
$0[Account[.id]] = account.id
102+
}
103+
$0[.update] = .init
104+
{
105+
$0[.set] = .init
106+
{
107+
$0[Account[.id]] = account.id
108+
$0[Account[.role]] = account.role
109+
$0[Account[.user]] = account.user
110+
}
111+
$0[.setOnInsert] = .init
112+
{
113+
$0[Account[.cookie]] = Int64.random(in: .min ... .max)
114+
}
115+
}
116+
},
117+
against: self.database)
118+
119+
return upserted.cookie
120+
}
37121
}

Sources/UnidocDatabase/Accounts/Account.Database.swift

Lines changed: 4 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -17,65 +17,14 @@ extension Account
1717
}
1818
extension Account.Database
1919
{
20+
@inlinable public
2021
var users:Users { .init(database: self.id) }
2122
}
22-
extension Account.Database
23-
{
24-
public static
25-
func setup(as id:Mongo.Database, in pool:__owned Mongo.SessionPool) async throws -> Self
26-
{
27-
let database:Self = .init(id: id)
28-
try await database.setup(with: try await .init(from: pool))
29-
return database
30-
}
31-
32-
private
33-
func setup(with session:Mongo.Session) async throws
34-
{
35-
do
36-
{
37-
try await self.users.setup(with: session)
38-
}
39-
catch let error
40-
{
41-
print("""
42-
warning: some indexes are no longer valid. \
43-
the database '\(self.id)' likely needs to be rebuilt.
44-
""")
45-
print(error)
46-
}
47-
}
48-
}
49-
extension Account.Database
23+
extension Account.Database:DatabaseModel
5024
{
5125
public
52-
func upsert(account:__owned Account, with session:Mongo.Session) async throws
53-
{
54-
try await self.users.upsert(account, with: session)
55-
}
56-
57-
public
58-
func cookie(_ value:String,
59-
indicates role:Account.Role,
60-
with session:Mongo.Session) async throws -> Bool
26+
func setup(with session:Mongo.Session) async throws
6127
{
62-
// FIXME: we don’t need to return the entire account document here.
63-
let matches:[Account] = try await session.run(
64-
command: Mongo.Find<Mongo.SingleBatch<Account>>.init(Users.name, limit: 1)
65-
{
66-
$0[.filter] = .init
67-
{
68-
$0[Account[.session]] = value
69-
$0[Account[.role]] = role
70-
}
71-
$0[.hint] = .init
72-
{
73-
$0[Account[.session]] = (+)
74-
$0[Account[.role]] = (+)
75-
}
76-
},
77-
against: self.id)
78-
79-
return !matches.isEmpty
28+
try await self.users.setup(with: session)
8029
}
8130
}

Sources/UnidocDatabase/Accounts/Account.ID.swift

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,27 @@ extension Account.ID:RawRepresentable
3838
extension Account.ID:BSONDecodable, BSONEncodable
3939
{
4040
}
41+
extension Account.ID:CustomStringConvertible
42+
{
43+
@inlinable public
44+
var description:String
45+
{
46+
"\(UInt64.init(bitPattern: self.rawValue))"
47+
}
48+
}
49+
extension Account.ID:LosslessStringConvertible
50+
{
51+
@inlinable public
52+
init?(_ description:some StringProtocol)
53+
{
54+
if let value:UInt64 = .init(description),
55+
let value:Self = .init(rawValue: .init(bitPattern: value))
56+
{
57+
self = value
58+
}
59+
else
60+
{
61+
return nil
62+
}
63+
}
64+
}

0 commit comments

Comments
 (0)