From 7947eaf86246fb582bba41dc94d388ade41cf6e3 Mon Sep 17 00:00:00 2001 From: Jan Dittberner Date: Fri, 1 Jan 2021 14:21:26 +0100 Subject: [PATCH] Implement client certificate login --- active.de.toml | 18 ++ active.en.toml | 10 + cmd/idp/main.go | 30 ++- idp/handlers/consent.go | 2 +- idp/handlers/login.go | 311 +++++++++++++++++------- idp/services/i18n.go | 18 ++ templates/idp/client_certificate.gohtml | 14 ++ templates/idp/login.gohtml | 1 + translate.de.toml | 22 +- 9 files changed, 307 insertions(+), 119 deletions(-) create mode 100644 templates/idp/client_certificate.gohtml diff --git a/active.de.toml b/active.de.toml index 9553e99..97cc7e9 100644 --- a/active.de.toml +++ b/active.de.toml @@ -1,3 +1,11 @@ +[CertLoginIntroText] +hash = "sha1-3d611f594a2194a9b99f8b955e49877bd419a567" +other = "Du hast ein gültiges Client-Zertifikat für die folgenden E-Mail-Adressen vorgelegt:" + +[CertLoginRequestText] +hash = "sha1-c1ad2600848ad6293ae6df6a04b0b2318bb303f9" +other = "Willst du dieses Zertifikat für die Anmeldung verwenden oder möchtest du lieber ein anderes Verfahren nutzen?" + [ErrorEmail] hash = "sha1-d2306dd8970ff616631a3501791297f31475e416" other = "Bitte gib eine gültige E-Mailadresse ein." @@ -48,6 +56,11 @@ other = "Auf der Beschreibungsseite findest du hash = "sha1-cb8efc74b5b726201321e0924747bf38d39629a1" other = "Die Anwendung {{ .client }} benötigt deine Einwilligungung für die angefragten Berechtigungen." +[LabelAcceptCertLogin] +description = "Label for a button to accept certificate login" +hash = "sha1-95cf27f4bdee62b51ee8bc673d25a46bcceed452" +other = "Ja, bitte nutze das Zertifikat" + [LabelConsent] hash = "sha1-5e56a367cf99015bbe98488845541db00b7e04f6" other = "Ich erteile hiermit meine Einwilligung, dass die Anwendung die angefragten Berechtigungen erhalten darf." @@ -57,6 +70,11 @@ description = "Label for a login button" hash = "sha1-ff00822024fca849fe0cef21237b57218e706852" other = "Anmelden" +[LabelRejectCertLogin] +description = "Label for a button to reject certificate login" +hash = "sha1-911cc305bb66efe162641969aee6b88e5d28e24f" +other = "Nein, bitte frag nach meinem Passwort" + [LabelSubmit] hash = "sha1-2dacf65959849884a011f36f76a04eebea94c5ea" other = "Abschicken" diff --git a/active.en.toml b/active.en.toml index 77a08ed..75769f9 100644 --- a/active.en.toml +++ b/active.en.toml @@ -1,3 +1,5 @@ +CertLoginIntroText = "You have presented a valid client certificate for the following email addresses:" +CertLoginRequestText = "Do you want to use this certificate for authentication or do you want to use a different method?" ErrorEmail = "Please enter a valid email address." ErrorEmailRequired = "Please enter an email address." ErrorPasswordRequired = "Please enter a password." @@ -26,10 +28,18 @@ other = "Email:" description = "Label for a password form field" other = "Password:" +[LabelAcceptCertLogin] +description = "Label for a button to accept certificate login" +other = "Yes, please use the certificate" + [LabelLogin] description = "Label for a login button" other = "Login" +[LabelRejectCertLogin] +description = "Label for a button to reject certificate login" +other = "No, please ask for my password" + [LogoutLabel] description = "A label on a logout button or link" other = "Logout" diff --git a/cmd/idp/main.go b/cmd/idp/main.go index 81826aa..27d67d1 100644 --- a/cmd/idp/main.go +++ b/cmd/idp/main.go @@ -3,8 +3,10 @@ package main import ( "context" "crypto/tls" + "crypto/x509" "encoding/base64" "fmt" + "io/ioutil" "net/http" "net/url" "os" @@ -48,12 +50,13 @@ func main() { config := koanf.New(".") _ = config.Load(confmap.Provider(map[string]interface{}{ - "server.port": 3000, - "server.name": "login.cacert.localhost", - "server.key": "certs/idp.cacert.localhost.key", - "server.certificate": "certs/idp.cacert.localhost.crt.pem", - "admin.url": "https://hydra.cacert.localhost:4445/", - "i18n.languages": []string{"en", "de"}, + "server.port": 3000, + "server.name": "login.cacert.localhost", + "server.key": "certs/idp.cacert.localhost.key", + "server.certificate": "certs/idp.cacert.localhost.crt.pem", + "security.client.ca-file": "certs/client_ca.pem", + "admin.url": "https://hydra.cacert.localhost:4445/", + "i18n.languages": []string{"en", "de"}, }, "."), nil) cFiles, _ := f.GetStringSlice("conf") for _, c := range cFiles { @@ -133,16 +136,25 @@ func main() { csrf.SameSite(csrf.SameSiteStrictMode), csrf.MaxAge(600)) + clientCertPool := x509.NewCertPool() + pemBytes, err := ioutil.ReadFile(config.MustString("security.client.ca-file")) + if err != nil { + logger.Fatalf("could not load client CA certificates: %v", err) + } + clientCertPool.AppendCertsFromPEM(pemBytes) + tlsConfig := &tls.Config{ ServerName: config.String("server.name"), MinVersion: tls.VersionTLS12, + ClientAuth: tls.VerifyClientCertIfGiven, + ClientCAs: clientCertPool, } server := &http.Server{ Addr: fmt.Sprintf("%s:%d", config.String("server.name"), config.Int("server.port")), Handler: tracing(logging(hsts(csrfProtect(router)))), - ReadTimeout: 5 * time.Second, - WriteTimeout: 10 * time.Second, - IdleTimeout: 15 * time.Second, + ReadTimeout: 20 * time.Second, + WriteTimeout: 20 * time.Second, + IdleTimeout: 30 * time.Second, TLSConfig: tlsConfig, } diff --git a/idp/handlers/consent.go b/idp/handlers/consent.go index 0cccf14..08f383a 100644 --- a/idp/handlers/consent.go +++ b/idp/handlers/consent.go @@ -99,7 +99,7 @@ func (h *consentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { r.Context(), `SELECT email, verified, fname, mname, lname, dob, language, modified FROM users -WHERE id = ? +WHERE uniqueID = ? AND LOCKED = 0`, ) if err != nil { diff --git a/idp/handlers/login.go b/idp/handlers/login.go index 24b584b..ec9e8e4 100644 --- a/idp/handlers/login.go +++ b/idp/handlers/login.go @@ -13,6 +13,7 @@ import ( "github.com/go-playground/form/v4" "github.com/go-playground/validator/v10" "github.com/gorilla/csrf" + "github.com/jmoiron/sqlx" "github.com/nicksnyder/go-i18n/v2/i18n" "github.com/ory/hydra-client-go/client/admin" "github.com/ory/hydra-client-go/models" @@ -22,27 +23,26 @@ import ( "git.cacert.org/oidc_login/idp/services" ) +type acrType string + +const ( + ClientCertificate acrType = "cert" // client certificate login + Password acrType = "password" // regular username + password login + // ClientCertificateOTP acrType = "cert+otp" + // ClientCertificateToken acrType = "cert+token" + // PasswordOTP acrType = "password+otp" + // PasswordToken acrType = "password+token" +) + type loginHandler struct { adminClient *admin.Client bundle *i18n.Bundle context context.Context logger *log.Logger - loginTemplate *template.Template + templates map[acrType]*template.Template messageCatalog *commonServices.MessageCatalog } -type acrType string - -const ( - NoCredentials acrType = "none" - ClientCertificate acrType = "cert" - ClientCertificateOTP acrType = "cert+otp" - ClientCertificateToken acrType = "cert+token" - Password acrType = "password" - PasswordOTP acrType = "password+otp" - PasswordToken acrType = "password+token" -) - type LoginInformation struct { Email string `form:"email" validate:"required,email"` Password string `form:"password" validate:"required"` @@ -55,104 +55,130 @@ func (h *loginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { accept := r.Header.Get("Accept-Language") localizer := i18n.NewLocalizer(h.bundle, accept) + certEmails := h.getCertEmails(r) + + var loginInfo LoginInformation validate := validator.New() switch r.Method { case http.MethodGet: - // render login form - h.renderLoginForm(w, r, map[string]string{}, &LoginInformation{}, localizer) + if certEmails != nil { + h.renderRequestForClientCert(w, r, certEmails, localizer) + } else { + // render login form + h.renderLoginForm(w, r, map[string]string{}, &LoginInformation{}, localizer) + } break case http.MethodPost: - var loginInfo LoginInformation + var userId *string + var authMethod acrType - // validate input - decoder := form.NewDecoder() - err = decoder.Decode(&loginInfo, r.Form) - if err != nil { - h.logger.Error(err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - err := validate.Struct(&loginInfo) - if err != nil { - errors := make(map[string]string) - for _, err := range err.(validator.ValidationErrors) { - accept := r.Header.Get("Accept-Language") - errors[err.Field()] = h.messageCatalog.LookupErrorMessage(err.Tag(), err.Field(), err.Value(), i18n.NewLocalizer(h.bundle, accept)) - } - h.renderLoginForm(w, r, errors, &loginInfo, localizer) - return - } - - db := services.GetDb(h.context) - - stmt, err := db.PrepareContext( - r.Context(), - `SELECT id -FROM users -WHERE email = ? - AND password = ? - AND locked = 0`, - ) - if err != nil { - h.logger.Errorf("error preparing login SQL: %v", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - defer func() { _ = stmt.Close() }() - - // FIXME: replace with a real password hash algorithm - passwordHash := sha1.Sum([]byte(loginInfo.Password)) - password := hex.EncodeToString(passwordHash[:]) - // FIXME: introduce a real opaque identifier (i.e. a UUID) - var userId string - // GET user data - err = stmt.QueryRowContext(r.Context(), loginInfo.Email, password).Scan(&userId) - switch { - case err == sql.ErrNoRows: - errors := map[string]string{ - "Form": h.messageCatalog.LookupMessage( - "WrongOrLockedUserOrInvalidPassword", - nil, - localizer, - ), - } - h.renderLoginForm(w, r, errors, &loginInfo, localizer) - return - case err != nil: - h.logger.Errorf("error performing login SQL: %v", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - default: - // finish login and redirect to target - loginRequest, err := h.adminClient.AcceptLoginRequest( - admin.NewAcceptLoginRequestParams().WithLoginChallenge(challenge).WithBody(&models.AcceptLoginRequest{ - Acr: string(Password), - Remember: true, - RememberFor: 0, - Subject: &userId, - }).WithTimeout(time.Second * 10)) - if err != nil { - h.logger.Errorf("error getting login request: %#v", err) - http.Error(w, err.Error(), err.(*runtime.APIError).Code) + if certEmails != nil && r.PostFormValue("action") == "cert-login" { + if r.PostFormValue("use-certificate") == "" { + // render login form + h.renderLoginForm(w, r, map[string]string{}, &LoginInformation{}, localizer) return } - w.Header().Add("Location", *loginRequest.GetPayload().RedirectTo) - w.WriteHeader(http.StatusFound) + // perform certificate auth + h.logger.Infof("would perform certificate authentication with: %+v", certEmails) + userId, err = h.performCertificateLogin(certEmails, r) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if userId == nil { + errors := map[string]string{ + "Form": h.messageCatalog.LookupMessage( + "WrongOrLockedUserOrInvalidPassword", + nil, + localizer, + ), + } + h.renderLoginForm(w, r, errors, &loginInfo, localizer) + return + } + authMethod = ClientCertificate + } else { + decoder := form.NewDecoder() + // validate input + err = decoder.Decode(&loginInfo, r.Form) + if err != nil { + h.logger.Error(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + err := validate.Struct(&loginInfo) + if err != nil { + errors := make(map[string]string) + for _, err := range err.(validator.ValidationErrors) { + accept := r.Header.Get("Accept-Language") + errors[err.Field()] = h.messageCatalog.LookupErrorMessage( + err.Tag(), + err.Field(), + err.Value(), + i18n.NewLocalizer(h.bundle, accept), + ) + } + h.renderLoginForm(w, r, errors, &loginInfo, localizer) + return + } + userId, err = h.performUserNamePasswordLogin(&loginInfo, r) + if err != nil { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + if userId == nil { + errors := map[string]string{ + "Form": h.messageCatalog.LookupMessage( + "WrongOrLockedUserOrInvalidPassword", + nil, + localizer, + ), + } + h.renderLoginForm(w, r, errors, &loginInfo, localizer) + return + } + authMethod = Password } - break + + // finish login and redirect to target + loginRequest, err := h.adminClient.AcceptLoginRequest( + admin.NewAcceptLoginRequestParams().WithLoginChallenge(challenge).WithBody(&models.AcceptLoginRequest{ + Acr: string(authMethod), + Remember: true, + RememberFor: 0, + Subject: userId, + }).WithTimeout(time.Second * 10)) + if err != nil { + h.logger.Errorf("error getting login request: %#v", err) + http.Error(w, err.Error(), err.(*runtime.APIError).Code) + return + } + w.Header().Add("Location", *loginRequest.GetPayload().RedirectTo) + w.WriteHeader(http.StatusFound) default: http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } } +func (h *loginHandler) getCertEmails(r *http.Request) []string { + if r.TLS != nil && r.TLS.PeerCertificates != nil && len(r.TLS.PeerCertificates) > 0 { + firstCert := r.TLS.PeerCertificates[0] + for _, email := range firstCert.EmailAddresses { + h.logger.Infof("authenticated with a client certificate for email address %s", email) + } + return firstCert.EmailAddresses + } + return nil +} + func (h *loginHandler) renderLoginForm(w http.ResponseWriter, r *http.Request, errors map[string]string, info *LoginInformation, localizer *i18n.Localizer) { trans := func(label string) string { return h.messageCatalog.LookupMessage(label, nil, localizer) } - err := h.loginTemplate.Lookup("base").Execute(w, map[string]interface{}{ + err := h.templates[Password].Lookup("base").Execute(w, map[string]interface{}{ "Title": trans("LoginTitle"), csrf.TemplateTag: csrf.TemplateField(r), "LabelEmail": trans("LabelEmail"), @@ -168,18 +194,121 @@ func (h *loginHandler) renderLoginForm(w http.ResponseWriter, r *http.Request, e } } +func (h *loginHandler) renderRequestForClientCert(w http.ResponseWriter, r *http.Request, emails []string, localizer *i18n.Localizer) { + trans := func(label string) string { + return h.messageCatalog.LookupMessage(label, nil, localizer) + } + + err := h.templates[ClientCertificate].Lookup("base").Execute(w, map[string]interface{}{ + "Title": trans("LoginTitle"), + csrf.TemplateTag: csrf.TemplateField(r), + "IntroText": trans("CertLoginIntroText"), + "emails": emails, + "RequestText": trans("CertLoginRequestText"), + "AcceptLabel": trans("LabelAcceptCertLogin"), + "RejectLabel": trans("LabelRejectCertLogin"), + }) + if err != nil { + h.logger.Error(err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } +} + +func (h *loginHandler) performUserNamePasswordLogin(loginInfo *LoginInformation, r *http.Request) (*string, error) { + db := services.GetDb(h.context) + + stmt, err := db.PrepareContext( + r.Context(), + `SELECT uniqueID +FROM users +WHERE email = ? + AND password = ? + AND locked = 0`, + ) + if err != nil { + h.logger.Errorf("error preparing login SQL: %v", err) + return nil, err + } + defer func() { _ = stmt.Close() }() + + // FIXME: replace with a real password hash algorithm + passwordHash := sha1.Sum([]byte(loginInfo.Password)) + password := hex.EncodeToString(passwordHash[:]) + + var userId string + // GET user data + err = stmt.QueryRowContext(r.Context(), loginInfo.Email, password).Scan(&userId) + switch { + case err == sql.ErrNoRows: + return nil, nil + case err != nil: + h.logger.Errorf("error performing login SQL: %v", err) + return nil, err + default: + h.logger.Infof("found user %s", userId) + return &userId, nil + } +} + +func (h *loginHandler) performCertificateLogin(emails []string, r *http.Request) (*string, error) { + db := services.GetDb(h.context) + + query, args, err := sqlx.In( + `SELECT DISTINCT u.uniqueID +FROM users u + JOIN email e ON e.memid = u.id +WHERE e.email IN (?) + AND u.locked = 0`, + emails, + ) + if err != nil { + h.logger.Errorf("could not parse IN query for certificate login: %v", err) + return nil, err + } + stmt, err := db.PreparexContext(r.Context(), query) + if err != nil { + h.logger.Errorf("error preparing login SQL: %v", err) + return nil, err + } + defer func() { _ = stmt.Close() }() + + var userId string + err = stmt.QueryRowContext(r.Context(), args...).Scan(&userId) + switch { + case err == sql.ErrNoRows: + return nil, nil + case err != nil: + h.logger.Errorf("error performing login SQL: %v", err) + return nil, err + default: + h.logger.Infof("found user %s", userId) + return &userId, nil + } +} + func NewLoginHandler(ctx context.Context, logger *log.Logger) (*loginHandler, error) { + var err error loginTemplate, err := template.ParseFiles( "templates/idp/base.gohtml", "templates/idp/login.gohtml") if err != nil { return nil, err } + clientCertTemplate, err := template.ParseFiles( + "templates/idp/base.gohtml", "templates/idp/client_certificate.gohtml") + if err != nil { + return nil, err + } + formTemplates := map[acrType]*template.Template{ + Password: loginTemplate, + ClientCertificate: clientCertTemplate, + } return &loginHandler{ adminClient: ctx.Value(CtxAdminClient).(*admin.Client), bundle: commonServices.GetI18nBundle(ctx), context: ctx, logger: logger, - loginTemplate: loginTemplate, + templates: formTemplates, messageCatalog: commonServices.GetMessageCatalog(ctx), }, nil } diff --git a/idp/services/i18n.go b/idp/services/i18n.go index 6d682f3..a35eed5 100644 --- a/idp/services/i18n.go +++ b/idp/services/i18n.go @@ -89,5 +89,23 @@ func AddMessages(ctx context.Context) { Description: "Label for a login button", Other: "Login", } + messages["CertLoginIntroText"] = &i18n.Message{ + ID: "CertLoginIntroText", + Other: "You have presented a valid client certificate for the following email addresses:", + } + messages["CertLoginRequestText"] = &i18n.Message{ + ID: "CertLoginRequestText", + Other: "Do you want to use this certificate for authentication or do you want to use a different method?", + } + messages["LabelAcceptCertLogin"] = &i18n.Message{ + ID: "LabelAcceptCertLogin", + Description: "Label for a button to accept certificate login", + Other: "Yes, please use the certificate", + } + messages["LabelRejectCertLogin"] = &i18n.Message{ + ID: "LabelRejectCertLogin", + Description: "Label for a button to reject certificate login", + Other: "No, please ask for my password", + } services.GetMessageCatalog(ctx).AddMessages(messages) } diff --git a/templates/idp/client_certificate.gohtml b/templates/idp/client_certificate.gohtml new file mode 100644 index 0000000..22d85ae --- /dev/null +++ b/templates/idp/client_certificate.gohtml @@ -0,0 +1,14 @@ +{{ define "content" }} +

{{ .IntroText }}

+ +

{{ .RequestText }}

+
+ {{ .csrfField }} + + + +
+{{ end }} \ No newline at end of file diff --git a/templates/idp/login.gohtml b/templates/idp/login.gohtml index 0c9ee6f..7196446 100644 --- a/templates/idp/login.gohtml +++ b/templates/idp/login.gohtml @@ -1,6 +1,7 @@ {{ define "content" }}
{{ .csrfField }} + {{ if .errors.Form}}

{{ .errors.Form }}

{{ end }} {{ if .errors.Email }}

{{ .errors.Email }}

{{ end }} diff --git a/translate.de.toml b/translate.de.toml index a983286..3ff4287 100644 --- a/translate.de.toml +++ b/translate.de.toml @@ -1,18 +1,4 @@ -[FormLabelEmail] -description = "Label for an email form field" -hash = "sha1-ce1aa6771caccb8c901c6627e7ab5c554e9944da" -other = "E-Mail:" - -[FormLabelPassword] -description = "Label for a password form field" -hash = "sha1-a3c9deb12ea191bb380ea7ad076417b1fff14f28" -other = "Passwort:" - -[LabelLogin] -description = "Label for a login button" -hash = "sha1-ff00822024fca849fe0cef21237b57218e706852" -other = "Anmelden" - -[LoginTitle] -hash = "sha1-4e5a2893bdcc7d239c1db72e4c4ffbe4bea73174" -other = "Anmeldung" +[LabelAcceptCertLogin] +description = "Label for a button to accept certificate login" +hash = "sha1-95cf27f4bdee62b51ee8bc673d25a46bcceed452" +other = "Ja, bitte nutze das Zertifikat"