389 lines
11 KiB
Go
389 lines
11 KiB
Go
/*
|
|
Copyright CAcert Inc.
|
|
SPDX-License-Identifier: Apache-2.0
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
https://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
*/
|
|
|
|
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"html/template"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
|
|
"github.com/gorilla/csrf"
|
|
"github.com/nicksnyder/go-i18n/v2/i18n"
|
|
client "github.com/ory/hydra-client-go/v2"
|
|
|
|
"code.cacert.org/cacert/oidc-idp/internal/services"
|
|
)
|
|
|
|
type acrType string
|
|
|
|
const (
|
|
// ClientCertificate represents a client certificate login
|
|
ClientCertificate acrType = "urn:cacert:1fa:cert"
|
|
// ClientCertificateOTP acrType = "urn:cacert:2fa:cert+otp"
|
|
// ClientCertificateToken acrType = "urn:cacert:2fa:cert+token"
|
|
)
|
|
|
|
type contextKey int
|
|
|
|
const (
|
|
ctxKeyMessage contextKey = iota
|
|
)
|
|
|
|
type LoginHandler struct {
|
|
logger *slog.Logger
|
|
trans *services.I18NService
|
|
adminClient client.OAuth2API
|
|
templates TemplateCache
|
|
}
|
|
|
|
func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet && r.Method != http.MethodPost {
|
|
http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
|
|
|
|
return
|
|
}
|
|
|
|
localizer := getLocalizer(h.trans, r)
|
|
|
|
challenge := r.URL.Query().Get("login_challenge")
|
|
|
|
if challenge == "" {
|
|
h.renderNoChallengeInRequest(w, localizer)
|
|
|
|
return
|
|
}
|
|
|
|
h.logger.Debug("received login challenge", "challenge", challenge)
|
|
|
|
certFullName, certEmails := getDataFromClientCert(h.logger, r)
|
|
|
|
if certEmails == nil {
|
|
renderNoEmailsInClientCertificate(h.logger, h.templates, h.trans, w, localizer)
|
|
|
|
return
|
|
}
|
|
|
|
if r.Method == http.MethodGet {
|
|
h.handleGet(w, r, challenge, certEmails, localizer)
|
|
} else {
|
|
h.handlePost(w, r, challenge, certFullName, certEmails, localizer)
|
|
}
|
|
}
|
|
|
|
func (h *LoginHandler) handleGet(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
challenge string,
|
|
certEmails []string,
|
|
localizer *i18n.Localizer,
|
|
) {
|
|
oAuth2LoginRequest, response, err := h.adminClient.GetOAuth2LoginRequest(
|
|
r.Context(),
|
|
).LoginChallenge(challenge).Execute()
|
|
if err != nil {
|
|
h.logger.Warn(
|
|
"could not get login request for challenge",
|
|
"error", err, "challenge", challenge,
|
|
)
|
|
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
usableEmails := certEmails
|
|
|
|
defer func() { _ = response.Body.Close() }()
|
|
|
|
h.logger.Debug(
|
|
"got response for GetOAuth2LoginRequest",
|
|
"response", response.Status, "login_request", oAuth2LoginRequest,
|
|
)
|
|
|
|
if subject, ok := oAuth2LoginRequest.GetSubjectOk(); ok && *subject != "" {
|
|
h.logger.Info("oauth2LoginRequest expects subject", "subject", *subject)
|
|
|
|
subjectInCert := false
|
|
|
|
for _, email := range certEmails {
|
|
if *subject == email {
|
|
subjectInCert = true
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
if !subjectInCert {
|
|
h.rejectLoginMissingSubject(w, r, challenge, localizer, *subject)
|
|
|
|
return
|
|
}
|
|
|
|
usableEmails = []string{*subject}
|
|
}
|
|
|
|
h.renderRequestForClientCert(w, r, usableEmails, localizer, oAuth2LoginRequest)
|
|
}
|
|
|
|
type FlashMessage struct {
|
|
Type string
|
|
Message string
|
|
}
|
|
|
|
func (h *LoginHandler) handlePost(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
challenge string,
|
|
certFullName string,
|
|
certEmails []string,
|
|
localizer *i18n.Localizer,
|
|
) {
|
|
if r.FormValue("use-identity") != "accept" {
|
|
h.rejectLogin(w, r, challenge, localizer)
|
|
|
|
return
|
|
}
|
|
|
|
if r.FormValue("email") == "" {
|
|
h.handleGet(w, r.WithContext(context.WithValue(
|
|
r.Context(),
|
|
ctxKeyMessage,
|
|
FlashMessage{
|
|
Type: "warning",
|
|
Message: h.trans.LookupMessage("NoEmailAddressSelected", nil, localizer),
|
|
},
|
|
)), challenge, certEmails, localizer)
|
|
|
|
return
|
|
}
|
|
|
|
// perform certificate auth
|
|
h.logger.Info(
|
|
"will perform certificate authentication",
|
|
"emails", certEmails, "full_name", certFullName,
|
|
)
|
|
|
|
userID, err := h.performCertificateLogin(certEmails, r)
|
|
if err != nil {
|
|
h.logger.Error("could not perform certificate login", "error", err)
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
session, err := GetSession(r)
|
|
if err != nil {
|
|
h.logger.Error("could not perform certificate login", "error", err)
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
session.Values[services.SessionFullName] = certFullName
|
|
session.Values[services.SessionEmail] = userID
|
|
session.Options.HttpOnly = true
|
|
session.Options.Secure = true
|
|
|
|
if err = session.Save(r, w); err != nil {
|
|
h.logger.Error("could not save session", "error", err)
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
// finish login and redirect to target
|
|
acceptRequest := client.NewAcceptOAuth2LoginRequest(userID)
|
|
acceptRequest.SetRemember(true)
|
|
acceptRequest.SetRememberFor(0)
|
|
acceptRequest.SetAcr(string(ClientCertificate))
|
|
|
|
loginRequest, response, err := h.adminClient.AcceptOAuth2LoginRequest(
|
|
r.Context(),
|
|
).LoginChallenge(challenge).AcceptOAuth2LoginRequest(*acceptRequest).Execute()
|
|
if err != nil {
|
|
h.logger.Error("error getting login request", "error", err)
|
|
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
defer func() { _ = response.Body.Close() }()
|
|
|
|
h.logger.Debug("got response for AcceptOAuth2LoginRequest",
|
|
"response", response.Status, "accept_login_request", loginRequest,
|
|
)
|
|
|
|
if h.logger.Enabled(r.Context(), slog.LevelDebug) {
|
|
if rb, err := io.ReadAll(response.Body); err == nil {
|
|
h.logger.Debug("response body from Hydra", "response_body", rb)
|
|
}
|
|
}
|
|
|
|
w.Header().Add("Location", loginRequest.GetRedirectTo())
|
|
w.WriteHeader(http.StatusFound)
|
|
}
|
|
|
|
func (h *LoginHandler) rejectLogin(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
challenge string,
|
|
localizer *i18n.Localizer,
|
|
) {
|
|
rejectRequest := client.NewRejectOAuth2RequestWithDefaults()
|
|
rejectRequest.SetErrorDescription(h.trans.LookupMessage("LoginDeniedByUser", nil, localizer))
|
|
rejectRequest.SetErrorHint(h.trans.LookupMessage("HintChooseAnIdentityForAuthentication", nil, localizer))
|
|
rejectRequest.SetStatusCode(http.StatusForbidden)
|
|
|
|
rejectLoginRequest, response, err := h.adminClient.RejectOAuth2LoginRequest(
|
|
r.Context(),
|
|
).LoginChallenge(challenge).RejectOAuth2Request(*rejectRequest).Execute()
|
|
if err != nil {
|
|
h.logger.Error("error sending reject login request", "error", err)
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
defer func() { _ = response.Body.Close() }()
|
|
|
|
h.logger.DebugContext(
|
|
r.Context(),
|
|
"got response for RejectOAuth2LoginRequest",
|
|
"response", response.Status, "reject_login_request", rejectLoginRequest,
|
|
)
|
|
|
|
w.Header().Set("Location", rejectLoginRequest.GetRedirectTo())
|
|
w.WriteHeader(http.StatusFound)
|
|
}
|
|
|
|
func (h *LoginHandler) rejectLoginMissingSubject(
|
|
w http.ResponseWriter, r *http.Request, challenge string, localizer *i18n.Localizer, subject string,
|
|
) {
|
|
rejectRequest := client.NewRejectOAuth2RequestWithDefaults()
|
|
rejectRequest.SetErrorDescription(h.trans.LookupMessage(
|
|
"LoginDeniedSubjectMissing", map[string]interface{}{"Subject": subject}, localizer),
|
|
)
|
|
rejectRequest.SetErrorHint(h.trans.LookupMessage("HintChooseDifferentClientCertificate", nil, localizer))
|
|
rejectRequest.SetStatusCode(http.StatusForbidden)
|
|
|
|
rejectLoginRequest, response, err := h.adminClient.RejectOAuth2LoginRequest(
|
|
r.Context()).LoginChallenge(challenge).RejectOAuth2Request(
|
|
*rejectRequest,
|
|
).Execute()
|
|
if err != nil {
|
|
h.logger.Error("error sending reject login request", "error", err)
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
defer func() { _ = response.Body.Close() }()
|
|
|
|
h.logger.DebugContext(
|
|
r.Context(),
|
|
"got response for RejectOAuth2LoginRequest",
|
|
"response", response.Status, "reject_login_request", rejectLoginRequest,
|
|
)
|
|
|
|
w.Header().Set("Location", rejectLoginRequest.GetRedirectTo())
|
|
w.WriteHeader(http.StatusFound)
|
|
}
|
|
|
|
func (h *LoginHandler) renderRequestForClientCert(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
emails []string,
|
|
localizer *i18n.Localizer,
|
|
loginRequest *client.OAuth2LoginRequest,
|
|
) {
|
|
msg := h.trans.LookupMessage
|
|
msgPlural := h.trans.LookupMessagePlural
|
|
msgMarkdown := h.trans.LookupMarkdownMessage
|
|
|
|
rendered := bytes.NewBuffer(make([]byte, 0))
|
|
|
|
err := h.templates[CertificateLogin].Lookup("base").Execute(rendered, map[string]interface{}{
|
|
"Title": msg("LoginTitle", nil, localizer),
|
|
csrf.TemplateTag: csrf.TemplateField(r),
|
|
"IntroText": template.HTML(msgMarkdown( //nolint:gosec
|
|
"CertLoginIntroText",
|
|
map[string]interface{}{"ClientName": loginRequest.Client.ClientName},
|
|
localizer,
|
|
)),
|
|
"EmailChoiceText": msgPlural("EmailChoiceText", nil, localizer, len(emails)),
|
|
"emails": emails,
|
|
"RequestText": msg("CertLoginRequestText", nil, localizer),
|
|
"AcceptLabel": msg("LabelAcceptCertLogin", nil, localizer),
|
|
"RejectLabel": msg("LabelRejectCertLogin", nil, localizer),
|
|
"FlashMessage": r.Context().Value(ctxKeyMessage),
|
|
})
|
|
if err != nil {
|
|
h.logger.Error("template rendering failed", "error", err)
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
|
|
w.Header().Add("Pragma", "no-cache")
|
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
_, _ = w.Write(rendered.Bytes())
|
|
}
|
|
|
|
func (h *LoginHandler) performCertificateLogin(emails []string, r *http.Request) (string, error) {
|
|
requestedEmail := r.PostFormValue("email")
|
|
for _, email := range emails {
|
|
if email == requestedEmail {
|
|
return email, nil
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("no user found")
|
|
}
|
|
|
|
func (h *LoginHandler) renderNoChallengeInRequest(w http.ResponseWriter, localizer *i18n.Localizer) {
|
|
err := h.templates[NoChallengeInRequest].Lookup("base").Execute(w, map[string]interface{}{
|
|
"Title": h.trans.LookupMessage("NoChallengeInRequestTitle", nil, localizer),
|
|
"Explanation": template.HTML(h.trans.LookupMarkdownMessage( //nolint:gosec
|
|
"NoChallengeInRequestExplanation",
|
|
nil,
|
|
localizer,
|
|
)),
|
|
})
|
|
if err != nil {
|
|
h.logger.Error("template rendering failed", "error", err)
|
|
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
|
|
|
return
|
|
}
|
|
}
|
|
|
|
func NewLoginHandler(
|
|
logger *slog.Logger, tc TemplateCache, trans *services.I18NService, adminClient client.OAuth2API,
|
|
) *LoginHandler {
|
|
return &LoginHandler{
|
|
logger: logger,
|
|
trans: trans,
|
|
adminClient: adminClient,
|
|
templates: tc,
|
|
}
|
|
}
|