Skip to content

Commit

Permalink
feat(webauthn): add a way to manage webauthn authenticators
Browse files Browse the repository at this point in the history
  • Loading branch information
Darkness4 committed Jan 13, 2024
1 parent 9e83e72 commit f0a5059
Show file tree
Hide file tree
Showing 15 changed files with 607 additions and 44 deletions.
8 changes: 4 additions & 4 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ func (a *Auth) CallBack() http.HandlerFunc {
return
}

token, err := a.JWTSecret.GenerateToken(userID, userName)
token, err := a.JWTSecret.GenerateToken(userID, userName, strings.ToLower(p))
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
Expand Down Expand Up @@ -169,13 +169,13 @@ func (a *Auth) Middleware(next http.Handler) http.Handler {
}

// Store the claims in the request context for further use
ctx := context.WithValue(r.Context(), claimsContextKey{}, claims)
ctx := context.WithValue(r.Context(), claimsContextKey{}, *claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

// GetClaimsFromRequest is a helper function to fetch the JWT session token from an HTTP request.
func GetClaimsFromRequest(r *http.Request) (claims *jwt.Claims, ok bool) {
claims, ok = r.Context().Value(claimsContextKey{}).(*jwt.Claims)
func GetClaimsFromRequest(r *http.Request) (claims jwt.Claims, ok bool) {
claims, ok = r.Context().Value(claimsContextKey{}).(jwt.Claims)
return claims, ok
}
231 changes: 229 additions & 2 deletions auth/webauthn/webauthn.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,22 @@ func (s *Service) FinishLogin() http.HandlerFunc {
return
}

// Re-fetch
user, err = s.users.Get(r.Context(), user.ID)
if err != nil {
log.Err(err).Any("user", user).Msg("failed to fetch user")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

log.Info().Any("credential", credential).Any("user", user).Msg("user logged")

// Identity is now verified
token, err := s.jwtSecret.GenerateToken(
base64.RawURLEncoding.EncodeToString(user.ID),
user.Name,
"webauthn",
jwt.WithCredentials(user.Credentials),
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
Expand All @@ -156,6 +168,10 @@ func (s *Service) FinishLogin() http.HandlerFunc {
}
}

// BeginRegistration beings the webauthn flow.
//
// Based on the user identity, webauthn will generate options for the authenticator.
// We send the options over JSON (not very htmx).
func (s *Service) BeginRegistration() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
Expand All @@ -169,6 +185,12 @@ func (s *Service) BeginRegistration() http.HandlerFunc {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

if len(user.Credentials) > 0 {
// The user has already been registered. We must login.
http.Error(w, "the user is already registered", http.StatusForbidden)
return
}
registerOptions := func(credCreationOpts *protocol.PublicKeyCredentialCreationOptions) {
credCreationOpts.CredentialExcludeList = user.ExcludeCredentialDescriptorList()
}
Expand Down Expand Up @@ -198,6 +220,11 @@ func (s *Service) BeginRegistration() http.HandlerFunc {
}
}

// FinishRegistration finishes the webauthn flow.
//
// The user has created options based on the options. We fetch the registration
// session from the session store.
// We complete the registration.
func (s *Service) FinishRegistration() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
Expand Down Expand Up @@ -229,19 +256,169 @@ func (s *Service) FinishRegistration() http.HandlerFunc {
}

// If creation was successful, store the credential object
// Pseudocode to add the user credential.
if err := s.users.AddCredential(r.Context(), user.ID, credential); err != nil {
log.Err(err).Any("user", user).Msg("user failed to add credential during registration")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

log.Info().Any("user", user).Msg("user created")
// Re-fetch
user, err = s.users.Get(r.Context(), user.ID)
if err != nil {
log.Err(err).Any("user", user).Msg("failed to fetch user")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

log.Info().Any("credential", credential).Any("user", user).Msg("user created")

// Identity is now verified
token, err := s.jwtSecret.GenerateToken(
base64.RawURLEncoding.EncodeToString(user.ID),
user.Name,
"webauthn",
jwt.WithCredentials(user.Credentials),
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

cookie := &http.Cookie{
Name: auth.TokenCookieKey,
Value: token,
Path: "/",
Expires: time.Now().Add(jwt.ExpiresDuration),
HttpOnly: true,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, "/", http.StatusFound)
}
}

// BeginAddDevice beings the webauthn registration flow.
//
// Based on the user identity, webauthn will generate options for the authenticator.
// We send the options over JSON (not very htmx).
//
// Compared to BeginRegistration, BeginAddDevice uses the JWT to allow the registration.
func (s *Service) BeginAddDevice() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.GetClaimsFromRequest(r)
if !ok {
http.Error(w, "session not found", http.StatusForbidden)
return
}

userID, err := base64.RawURLEncoding.DecodeString(claims.ID)
if err != nil {
log.Err(err).Any("claims", claims).Msg("failed to parse claims")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

user, err := s.users.Get(r.Context(), userID) // Find or create the new user
if err != nil {
log.Err(err).Any("user", user).Msg("failed to fetch user")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

registerOptions := func(credCreationOpts *protocol.PublicKeyCredentialCreationOptions) {
credCreationOpts.CredentialExcludeList = user.ExcludeCredentialDescriptorList()
}
options, session, err := s.webAuthn.BeginRegistration(user, registerOptions)
if err != nil {
log.Err(err).Any("user", user).Msg("user failed to begin registration")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

// store the session values
if err := s.store.Save(r.Context(), session); err != nil {
// Maybe a Fatal or Panic should be user here.
log.Err(err).Any("user", user).Msg("failed to save session in store")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

o, err := json.Marshal(options)
if err != nil {
log.Err(err).Any("user", user).Msg("failed to respond")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

w.Write(o)
}
}

// FinishAddDevice finishes the webauthn registration flow.
//
// The user has created options based on the options. We fetch the registration
// session from the session store.
// We complete the registration.
func (s *Service) FinishAddDevice() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.GetClaimsFromRequest(r)
if !ok {
http.Error(w, "session not found", http.StatusForbidden)
return
}

userID, err := base64.RawURLEncoding.DecodeString(claims.ID)
if err != nil {
log.Err(err).Any("claims", claims).Msg("failed to parse claims")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

user, err := s.users.Get(r.Context(), userID) // Find or create the new user
if err != nil {
log.Err(err).Any("user", user).Msg("failed to fetch user")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

// Get the session data stored from the function above
session, err := s.store.Get(r.Context(), user.ID)
if err != nil {
// Maybe a Fatal or Panic should be user here.
log.Err(err).Any("user", user).Msg("failed to save session in store")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

credential, err := s.webAuthn.FinishRegistration(user, *session, r)
if err != nil {
log.Err(err).Any("user", user).Msg("user failed to finish registration")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

// If creation was successful, store the credential object
if err := s.users.AddCredential(r.Context(), user.ID, credential); err != nil {
log.Err(err).Any("user", user).Msg("user failed to add credential during registration")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

// Re-fetch
user, err = s.users.Get(r.Context(), user.ID)
if err != nil {
log.Err(err).Any("user", user).Msg("failed to fetch user")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

log.Info().Any("credential", credential).Any("user", user).Msg("device added")

// Identity is now verified
token, err := s.jwtSecret.GenerateToken(
base64.RawURLEncoding.EncodeToString(user.ID),
user.Name,
"webauthn",
jwt.WithCredentials(user.Credentials),
)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
Expand All @@ -259,3 +436,53 @@ func (s *Service) FinishRegistration() http.HandlerFunc {
http.Redirect(w, r, "/", http.StatusFound)
}
}

// DeleteDevice deletes a webauthn credential.
func (s *Service) DeleteDevice() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
credential := r.URL.Query().Get("credential")
if credential == "" {
http.Error(w, "empty credential", http.StatusBadRequest)
return
}

cred, err := base64.RawURLEncoding.DecodeString(credential)
if err != nil {
log.Err(err).Str("credential", credential).Msg("failed to parse credential")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

claims, ok := auth.GetClaimsFromRequest(r)
if !ok {
http.Error(w, "session not found", http.StatusForbidden)
return
}

userID, err := base64.RawURLEncoding.DecodeString(claims.ID)
if err != nil {
log.Err(err).Any("claims", claims).Msg("failed to parse claims")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

user, err := s.users.Get(r.Context(), userID) // Find or create the new user
if err != nil {
log.Err(err).Any("user", user).Msg("failed to fetch user")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

if len(user.Credentials) <= 1 {
http.Error(w, "last credential cannot be deleted", http.StatusForbidden)
return
}

// If creation was successful, store the credential object
if err := s.users.RemoveCredential(r.Context(), user.ID, cred); err != nil {
log.Err(err).Any("user", user).Msg("user failed to remove credential")
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}
45 changes: 34 additions & 11 deletions base.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
<link
hx-preserve="true"
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@next/css/pico.classless.min.css"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@next/css/pico.min.css"
/>
<script
hx-preserve="true"
Expand All @@ -35,17 +35,40 @@
integrity="sha384-+Uth1QzYJsTjnS5SXVN3fFO4I32Y571xIuv53WJ2SA7y5/36tKU1VCutONAmg5eH"
crossorigin="anonymous"
></script>
<style hx-preserve="true">
body > header {
padding-top: 2em;
padding-bottom: 0;
}
<script
hx-preserve="true"
src="https://cdn.jsdelivr.net/npm/[email protected]/notyf.min.js"
integrity="sha384-uuNfwJfjOG2ukYi4eAB11/t3lP4Zjf75a3UhgkLzEpiX8JpJfacpG7Ye+0tiVMxT"
crossorigin="anonymous"
></script>
<script hx-preserve="true">
addEventListener('DOMContentLoaded', (event) => {
window.notyf = new Notyf({
duration: 10000,
ripple: false,
dismissible: true,
});

body > footer {
padding-top: 0;
padding-bottom: 2em;
}
</style>
document.addEventListener('htmx:responseError', function (e) {
notyf.error(e.detail.xhr.response);
});
});
</script>
<link
hx-preserve="true"
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/[email protected]/notyf.min.css"
integrity="sha384-snpJ3knpH6avB6cP1vPkNdmRzCYaCpom/3TNOyvo189BiogXYXQfXkyYpZ2/xADs"
crossorigin="anonymous"
/>
<link
hx-preserve="true"
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/flexboxgrid/6.3.1/flexboxgrid.min.css"
integrity="sha512-YHuwZabI2zi0k7c9vtg8dK/63QB0hLvD4thw44dFo/TfBFVVQOqEG9WpviaEpbyvgOIYLXF1n7xDUfU3GDs0sw=="
crossorigin="anonymous"
/>
<link hx-preserve="true" rel="stylesheet" href="/static/app.css" />
{{ template "head" . }}
</head>
<body hx-history="false" hx-ext="head-support">
Expand Down
Loading

0 comments on commit f0a5059

Please sign in to comment.