Previously I had my private services running in a Tailscale tailnet (vpn). I still love them, but... my Pixel 8 can't connect to multiple VPNs at once: I have Tailscale for private stuff, Google One for general purpose. So it's time to do zero trust and implement proper auth into applications.
WebAuthn has been around for a while, primarily used as an unphishable second factor: you provide a username, and password, and your hardware key signs a challenge from the server. It's unphishable because: it's hardware you need physical control over, and because the client (browser) is coopted into the security flow so challenges are domain bound: so typo domain squatting doesn't work, you can't relay a challenge from a different site By just signing the challenge, the hardware keys don't need to store any data for each site its used with. While commonly used as a second factor, it could in theory be used for passwordless flows with just a usernme.
WebAuthn/Fido2 also had a different way of working: Resident Keys, also known as Discoverable Credentials, or the most recent rebrand: Passkeys. This time the hardware key stores a dedicated key per relying party (site) and userid pair, allowing a site to directly query for a user. This in theory enables just using a usernameless/passwordless flow, but most large sites tend to ask for the username anyway. Because the hardware key is storing dedicated key material, the number of usable keys are somewhat limited (25 for a yubikey 5).
github.com/go-webauthn/webauthn
is pretty much the only implementation, and it's fine.
On the Go side, it's primarily
BeginDiscoverableLogin
,
securely store the session data (eg as a secure cookie),
and verify with
FinishDisoverableLogin
.
You'll want cookies to offload all the session datas to clients.
My code can be found here:
go.seankhliao.com/mono/cmd/authsvr
.
1func (a *App) startLogin() http.Handler {
2 return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
3 ctx, span := a.o.T.Start(r.Context(), "startLogin")
4 defer span.End()
5
6 data, wanSess, err := a.wan.BeginDiscoverableLogin()
7 if err != nil {
8 a.jsonErr(ctx, rw, "webauthn begin login", err, http.StatusInternalServerError, struct{}{})
9 return
10 }
11
12 wanSessCook, err := a.storeSecret("webauthn_login_start", wanSess)
13 if err != nil {
14 a.jsonErr(ctx, rw, "store session data", err, http.StatusInternalServerError, struct{}{})
15 return
16 }
17 http.SetCookie(rw, wanSessCook)
18
19 a.jsonOk(ctx, rw, data)
20 })
21}
22
23type LoginResponse struct {
24 Status string `json:"status"`
25 Redirect string `json:"redirect,omitempty"`
26}
27
28func (a *App) finishLogin() http.Handler {
29 return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
30 ctx, span := a.o.T.Start(r.Context(), "finishLogin")
31 defer span.End()
32
33 wanSessCook, err := r.Cookie("webauthn_login_start")
34 if err != nil {
35 a.jsonErr(ctx, rw, "get session cookie", err, http.StatusBadRequest, struct{}{})
36 return
37 }
38 var wanSess webauthn.SessionData
39 err = a.readSecret("webauthn_login_start", wanSessCook, &wanSess)
40 if err != nil {
41 a.jsonErr(ctx, rw, "read session cookie", err, http.StatusBadRequest, struct{}{})
42 return
43 }
44
45 // check
46 // rawID == credential id
47 // userHandle == user.id in creation request (from user.WebAuthnID)
48 handler := func(rawID, userHandle []byte) (webauthn.User, error) {
49 var user User
50 err := a.db.View(func(tx *bbolt.Tx) error {
51 bkt := tx.Bucket(bucketUser)
52 b := bkt.Get(userHandle)
53 err := json.Unmarshal(b, &user)
54 if err != nil {
55 return fmt.Errorf("decode user data: %w", err)
56 }
57 return nil
58 })
59 return user, err
60 }
61 cred, err := a.wan.FinishDiscoverableLogin(handler, wanSess, r)
62 if err != nil {
63 a.jsonErr(ctx, rw, "webauthn finish login", err, http.StatusBadRequest, struct{}{})
64 return
65 }
66
67 if cred.Authenticator.CloneWarning {
68 a.jsonErr(ctx, rw, "cloned authenticator", err, http.StatusBadRequest, struct{}{})
69 return
70 }
71
72 rawSessToken := make([]byte, 32)
73 rand.Read(rawSessToken)
74 sessToken := hex.EncodeToString(rawSessToken)
75 http.SetCookie(rw, &http.Cookie{
76 Name: AuthCookieName,
77 Value: sessToken,
78 Path: "/",
79 Domain: a.cookieDomain,
80 MaxAge: 60 * 60 * 24 * 365,
81 HttpOnly: true,
82 Secure: true,
83 SameSite: http.SameSiteLaxMode,
84 })
85
86 err = a.db.Update(func(tx *bbolt.Tx) error {
87 bkt := tx.Bucket(bucketCred)
88 email := bkt.Get(cred.ID)
89 bkt = tx.Bucket(bucketUser)
90 b := bkt.Get(email)
91 var user User
92 err := json.Unmarshal(b, &user)
93 if err != nil {
94 return fmt.Errorf("decode user data: %w", err)
95 }
96 for i := range user.Creds {
97 if string(user.Creds[i].ID) == string(cred.ID) {
98 user.Creds[i].Authenticator.SignCount = cred.Authenticator.SignCount
99 break
100 }
101 }
102 b, err = json.Marshal(user)
103 if err != nil {
104 return fmt.Errorf("encode user data: %w", err)
105 }
106 err = bkt.Put(email, b)
107 if err != nil {
108 return fmt.Errorf("update user data: %w", err)
109 }
110
111 info := SessionInfo{
112 UserID: user.ID,
113 Email: user.Email,
114 StartTime: time.Now(),
115 UserAgent: r.UserAgent(),
116 LoginCredID: hex.EncodeToString(cred.ID),
117 }
118 b, err = json.Marshal(info)
119 if err != nil {
120 return fmt.Errorf("encode sesion info: %w", err)
121 }
122
123 bkt = tx.Bucket(bucketSession)
124 err = bkt.Put([]byte(sessToken), b)
125 if err != nil {
126 return fmt.Errorf("store session token: %w", err)
127 }
128
129 return nil
130 })
131 if err != nil {
132 a.jsonErr(ctx, rw, "create new session", err, http.StatusBadRequest, struct{}{})
133 return
134 }
135
136 res := LoginResponse{
137 Status: "ok",
138 }
139
140 u, err := url.Parse(r.FormValue("redirect"))
141 if err == nil {
142 if strings.HasSuffix(u.Hostname(), "liao.dev") {
143 res.Redirect = u.String()
144 }
145 }
146
147 a.jsonOk(ctx, rw, res)
148 })
149}
150
151func (a *App) registerStart() http.Handler {
152 return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
153 ctx := r.Context()
154 ctx, span := a.o.T.Start(ctx, "registerStart")
155 defer span.End()
156
157 adminKey := r.FormValue("adminkey")
158 if adminKey != a.adminKey {
159 a.jsonErr(ctx, rw, "mismatched admin key", errors.New("unauthed admin key"), http.StatusUnauthorized, struct{}{})
160 return
161 }
162
163 email := r.PathValue("email")
164 if email == "" {
165 a.jsonErr(ctx, rw, "empty email pathvalue", errors.New("no email"), http.StatusBadRequest, struct{}{})
166 return
167 }
168
169 var user User
170 err := a.db.Update(func(tx *bbolt.Tx) error {
171 bkt := tx.Bucket(bucketUser)
172 b := bkt.Get([]byte(email))
173 if len(b) == 0 {
174 user.Email = email
175 id, _ := rand.Int(rand.Reader, big.NewInt(math.MaxInt64))
176 user.ID = id.Int64()
177 b, err := json.Marshal(user)
178 if err != nil {
179 return fmt.Errorf("marshal new user: %w", err)
180 }
181 return bkt.Put([]byte(email), b)
182 }
183 return json.Unmarshal(b, &user)
184 })
185 if err != nil {
186 a.jsonErr(ctx, rw, "get user from email", err, http.StatusInternalServerError, struct{}{})
187 return
188 }
189
190 var exlcusions []protocol.CredentialDescriptor
191 for _, cred := range user.Creds {
192 exlcusions = append(exlcusions, cred.Descriptor())
193 }
194
195 data, wanSess, err := a.wan.BeginRegistration(user, webauthn.WithExclusions(exlcusions))
196 if err != nil {
197 a.jsonErr(ctx, rw, "webauthn begin registration", err, http.StatusInternalServerError, struct{}{})
198 return
199 }
200
201 wanSessCook, err := a.storeSecret("webauthn_register_start", wanSess)
202 if err != nil {
203 a.jsonErr(ctx, rw, "store session cookie", err, http.StatusInternalServerError, struct{}{})
204 return
205 }
206 http.SetCookie(rw, wanSessCook)
207
208 a.jsonOk(ctx, rw, data)
209 })
210}
211
212func (a *App) registerFinish() http.Handler {
213 return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
214 ctx := r.Context()
215 ctx, span := a.o.T.Start(ctx, "registerFinish")
216 defer span.End()
217
218 adminKey := r.FormValue("adminkey")
219 if adminKey != a.adminKey {
220 a.jsonErr(ctx, rw, "mismatched admin key", errors.New("unauthed admin key"), http.StatusUnauthorized, struct{}{})
221 return
222 }
223
224 email := r.PathValue("email")
225 if email == "" {
226 a.jsonErr(ctx, rw, "empty email pathvalue", errors.New("no email"), http.StatusBadRequest, struct{}{})
227 return
228 }
229
230 wanSessCook, err := r.Cookie("webauthn_register_start")
231 if err != nil {
232 a.jsonErr(ctx, rw, "get session cookie", err, http.StatusBadRequest, struct{}{})
233 return
234 }
235 var wanSess webauthn.SessionData
236 err = a.readSecret("webauthn_register_start", wanSessCook, &wanSess)
237 if err != nil {
238 a.jsonErr(ctx, rw, "decode session cookie", err, http.StatusBadRequest, struct{}{})
239 return
240 }
241
242 err = a.db.Update(func(tx *bbolt.Tx) error {
243 bkt := tx.Bucket(bucketUser)
244 b := bkt.Get([]byte(email))
245 var user User
246 err := json.Unmarshal(b, &user)
247 if err != nil {
248 return fmt.Errorf("decode user: %w", err)
249 }
250
251 cred, err := a.wan.FinishRegistration(user, wanSess, r)
252 if err != nil {
253 return fmt.Errorf("finish registration: %w", err)
254 }
255 user.Creds = append(user.Creds, *cred)
256
257 b, err = json.Marshal(user)
258 if err != nil {
259 return fmt.Errorf("encode user: %w", err)
260 }
261
262 err = bkt.Put([]byte(email), b)
263 if err != nil {
264 return fmt.Errorf("update user")
265 }
266
267 bkt = tx.Bucket(bucketCred)
268 err = bkt.Put(cred.ID, []byte(email))
269 if err != nil {
270 return fmt.Errorf("link cred to user")
271 }
272 return nil
273 })
274 if err != nil {
275 a.jsonErr(ctx, rw, "store registration", err, http.StatusInternalServerError, err)
276 return
277 }
278
279 a.jsonOk(ctx, rw, struct{}{})
280 })
281}
On the client side, javascript is a necessity to call the navigator.credentials
Web Authentication API.
Also some helper functions to turn Go base64 bytes to the right encoding:
1// Base64url encode / decode, used by webauthn https://www.w3.org/TR/webauthn-2/
2function bufferEncode(value) {
3 return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
4 .replace(/\+/g, "-")
5 .replace(/\//g, "_")
6 .replace(/=/g, "");
7}
8function bufferDecode(value) {
9 return Uint8Array.from(
10 atob(value.replace(/-/g, "+").replace(/_/g, "/")),
11 (c) => c.charCodeAt(0),
12 );
13}
14// login
15async function loginUser() {
16 const startResponse = await fetch("/login/start", {
17 method: "POST",
18 });
19 if (!startResponse.ok) {
20 alert("failed to start");
21 return;
22 }
23 let opts = await startResponse.json();
24 opts.publicKey.challenge = bufferDecode(opts.publicKey.challenge);
25 if (opts.publicKey.allowCredentials) {
26 opts.publicKey.allowCredentials.forEach(
27 (it) => (it.id = bufferDecode(it.id)),
28 );
29 }
30 const assertion = await navigator.credentials.get({
31 publicKey: opts.publicKey,
32 });
33
34 // technically possible to do this all client side?
35 let windowParams = new URLSearchParams(document.location.search);
36 let params = new URLSearchParams({ redirect: windowParams.get("redirect") });
37 const finishResponse = await fetch(`/login/finish?${params}`, {
38 method: "POST",
39 body: JSON.stringify({
40 id: assertion.id,
41 rawId: bufferEncode(assertion.rawId),
42 type: assertion.type,
43 response: {
44 authenticatorData: bufferEncode(assertion.response.authenticatorData),
45 clientDataJSON: bufferEncode(assertion.response.clientDataJSON),
46 signature: bufferEncode(assertion.response.signature),
47 userHandle: bufferEncode(assertion.response.userHandle),
48 },
49 }),
50 });
51 if (!finishResponse.ok) {
52 alert("failed to login");
53 return;
54 }
55 const loginStatus = await finishResponse.json();
56 if (loginStatus.redirect) {
57 window.location.href = loginStatus.redirect;
58 return;
59 }
60 window.location.reload();
61}
62// register
63async function registerUser() {
64 let email = encodeURIComponent(document.querySelector("#email").value);
65 let adminKey = document.querySelector("#adminkey").value;
66 let params = new URLSearchParams({ adminkey: adminKey });
67
68 const startResponse = await fetch(`/register/${email}/start?${params}`, {
69 method: "POST",
70 });
71 if (!startResponse.ok) {
72 alert("failed to start");
73 }
74 let opts = await startResponse.json();
75 opts.publicKey.challenge = bufferDecode(opts.publicKey.challenge);
76 opts.publicKey.user.id = bufferDecode(opts.publicKey.user.id);
77 if (opts.publicKey.excludeCredentials) {
78 opts.publicKey.excludeCredentials.forEach(
79 (it) => (it.id = bufferDecode(it.id)),
80 );
81 }
82 const cred = await navigator.credentials.create({
83 publicKey: opts.publicKey,
84 });
85 const finishResponse = await fetch(`/register/${email}/finish?${params}`, {
86 method: "POST",
87 body: JSON.stringify({
88 id: cred.id,
89 rawId: bufferEncode(cred.rawId),
90 type: cred.type,
91 response: {
92 attestationObject: bufferEncode(cred.response.attestationObject),
93 clientDataJSON: bufferEncode(cred.response.clientDataJSON),
94 },
95 }),
96 });
97 if (!finishResponse.ok) {
98 alert("failed to register");
99 return;
100 }
101 alert("registered, plz login");
102}