Auth: implement consent logic
diff --git a/core/auth/ui/hydra.go b/core/auth/ui/hydra.go
new file mode 100644
index 0000000..8a0dea0
--- /dev/null
+++ b/core/auth/ui/hydra.go
@@ -0,0 +1,162 @@
+package main
+
+import (
+ "bytes"
+ "crypto/tls"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+type HydraClient struct {
+ httpClient *http.Client
+ host string
+}
+
+func NewHydraClient(host string) *HydraClient {
+ return &HydraClient{
+ // TODO(giolekva): trust selfsigned-root-ca automatically on pods
+ &http.Client{
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ },
+ },
+ host,
+ }
+}
+
+type loginResp struct {
+ RedirectTo string `json:"redirect_to"`
+ Error string `json:"error"`
+ ErrorDebug string `json:"error_debug"`
+ ErrorDescription string `json:"error_description"`
+ StatusCode int `json:"status_code"`
+}
+
+func (c *HydraClient) LoginAcceptChallenge(challenge, subject string) (string, error) {
+ req := &http.Request{
+ Method: http.MethodPut,
+ URL: &url.URL{
+ Scheme: "https",
+ Host: c.host,
+ Path: "/oauth2/auth/requests/login/accept",
+ RawQuery: fmt.Sprintf("login_challenge=%s", challenge),
+ },
+ Header: map[string][]string{
+ "Content-Type": []string{"application/json"},
+ },
+ // TODO(giolekva): user stable userid instead
+ Body: io.NopCloser(strings.NewReader(fmt.Sprintf(`
+{
+ "subject": "%s",
+ "remember": true,
+ "remember_for": 3600
+}`, subject))),
+ }
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ var r loginResp
+ if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
+ return "", err
+ }
+ if r.Error != "" {
+ return "", errors.New(r.Error)
+ }
+ return r.RedirectTo, nil
+}
+
+func (c *HydraClient) LoginRejectChallenge(challenge, message string) (string, error) {
+ req := &http.Request{
+ Method: http.MethodPut,
+ URL: &url.URL{
+ Scheme: "https",
+ Host: c.host,
+ Path: "/oauth2/auth/requests/login/reject",
+ RawQuery: fmt.Sprintf("login_challenge=%s", challenge),
+ },
+ Header: map[string][]string{
+ "Content-Type": []string{"application/json"},
+ },
+ Body: io.NopCloser(strings.NewReader(fmt.Sprintf(`
+{
+ "error": "login_required %s"
+}`, message))),
+ }
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ var r loginResp
+ if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
+ return "", err
+ }
+ if r.Error != "" {
+ return "", errors.New(r.Error)
+ }
+ return r.RedirectTo, nil
+}
+
+type RequestedConsent struct {
+ Challenge string `json:"challenge"`
+ Subject string `json:"subject"`
+ RequestedScopes []string `json:"requested_scope"`
+}
+
+func (c *HydraClient) GetConsentChallenge(challenge string) (RequestedConsent, error) {
+ var consent RequestedConsent
+ resp, err := c.httpClient.Get(fmt.Sprintf("https://%s/oauth2/auth/requests/consent?consent_challenge=%s", c.host, challenge))
+ if err != nil {
+ return consent, err
+ }
+ err = json.NewDecoder(resp.Body).Decode(&consent)
+ return consent, err
+}
+
+type consentAcceptReq struct {
+ GrantScope []string `json:"grant_scope"`
+ Session session `json:"session"`
+}
+
+type session struct {
+ IDToken map[string]string `json:"id_token"`
+}
+
+type consentAcceptResp struct {
+ RedirectTo string `json:"redirect_to"`
+}
+
+func (c *HydraClient) ConsentAccept(challenge string, scopes []string, idToken map[string]string) (string, error) {
+ accept := consentAcceptReq{scopes, session{idToken}}
+ var data bytes.Buffer
+ if err := json.NewEncoder(&data).Encode(accept); err != nil {
+ return "", err
+ }
+ req := &http.Request{
+ Method: http.MethodPut,
+ URL: &url.URL{
+ Scheme: "https",
+ Host: c.host,
+ Path: "/oauth2/auth/requests/consent/accept",
+ RawQuery: fmt.Sprintf("challenge=%s", challenge),
+ },
+ Header: map[string][]string{
+ "Content-Type": []string{"application/json"},
+ },
+ Body: io.NopCloser(&data),
+ }
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ var r consentAcceptResp
+ if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
+ return "", err
+ }
+ return r.RedirectTo, err
+}
diff --git a/core/auth/ui/main.go b/core/auth/ui/main.go
index 92ca885..a9b349d 100644
--- a/core/auth/ui/main.go
+++ b/core/auth/ui/main.go
@@ -2,7 +2,6 @@
import (
"bytes"
- "crypto/tls"
"embed"
"encoding/json"
"errors"
@@ -15,7 +14,6 @@
"net/http"
"net/http/cookiejar"
"net/url"
- "strings"
"github.com/gorilla/mux"
"github.com/itaysk/regogo"
@@ -23,6 +21,7 @@
var port = flag.Int("port", 8080, "Port to listen on")
var kratos = flag.String("kratos", "https://accounts.lekva.me", "Kratos URL")
+var hydra = flag.String("hydra", "hydra.pcloud", "Hydra admin server address")
var ErrNotLoggedIn = errors.New("Not logged in")
@@ -33,9 +32,14 @@
WhoAmI *template.Template
Registration *template.Template
Login *template.Template
+ Consent *template.Template
}
func ParseTemplates(fs embed.FS) (*Templates, error) {
+ whoami, err := template.ParseFS(fs, "templates/whoami.html")
+ if err != nil {
+ return nil, err
+ }
registration, err := template.ParseFS(fs, "templates/registration.html")
if err != nil {
return nil, err
@@ -44,15 +48,16 @@
if err != nil {
return nil, err
}
- whoami, err := template.ParseFS(fs, "templates/whoami.html")
+ consent, err := template.ParseFS(fs, "templates/consent.html")
if err != nil {
return nil, err
}
- return &Templates{whoami, registration, login}, nil
+ return &Templates{whoami, registration, login, consent}, nil
}
type Server struct {
kratos string
+ hydra *HydraClient
tmpls *Templates
}
@@ -63,6 +68,8 @@
r.Path("/registration").Methods(http.MethodPost).HandlerFunc(s.registration)
r.Path("/login").Methods(http.MethodGet).HandlerFunc(s.loginInitiate)
r.Path("/login").Methods(http.MethodPost).HandlerFunc(s.login)
+ r.Path("/consent").Methods(http.MethodGet).HandlerFunc(s.consent)
+ r.Path("/consent").Methods(http.MethodPost).HandlerFunc(s.processConsent)
r.Path("/logout").Methods(http.MethodGet).HandlerFunc(s.logout)
r.Path("/").HandlerFunc(s.whoami)
fmt.Printf("Starting HTTP server on port: %d\n", port)
@@ -112,7 +119,6 @@
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- log.Println(csrfToken)
w.Header().Set("Content-Type", "text/html")
if err := s.tmpls.Registration.Execute(w, csrfToken); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -172,17 +178,23 @@
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
+ if challenge, ok := r.Form["login_challenge"]; ok {
+ // TODO(giolekva): encrypt
+ http.SetCookie(w, &http.Cookie{
+ Name: "login_challenge",
+ Value: challenge[0],
+ HttpOnly: true,
+ })
+ } else {
+ // http.SetCookie(w, &http.Cookie{
+ // Name: "login_challenge",
+ // Value: "",
+ // Expires: time.Unix(0, 0),
+ // HttpOnly: true,
+ // })
+ }
flow, ok := r.Form["flow"]
if !ok {
- challenge, ok := r.Form["login_challenge"]
- if ok {
- // TODO(giolekva): encrypt
- http.SetCookie(w, &http.Cookie{
- Name: "login_challenge",
- Value: challenge[0],
- HttpOnly: true,
- })
- }
http.Redirect(w, r, s.kratos+"/self-service/login/browser", http.StatusSeeOther)
return
}
@@ -191,7 +203,6 @@
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- log.Println(csrfToken)
w.Header().Set("Content-Type", "text/html")
if err := s.tmpls.Login.Execute(w, csrfToken); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -286,6 +297,25 @@
}
+func extractError(r io.Reader) error {
+ respBody, err := ioutil.ReadAll(r)
+ if err != nil {
+ return err
+ }
+ t, err := regogo.Get(string(respBody), "input.ui.messages[0].type")
+ if err != nil {
+ return err
+ }
+ if t.String() == "error" {
+ message, err := regogo.Get(string(respBody), "input.ui.messages[0].text")
+ if err != nil {
+ return err
+ }
+ return errors.New(message.String())
+ }
+ return nil
+}
+
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
@@ -307,52 +337,41 @@
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- if resp, err := postToKratos("login", flow[0], r.Cookies(), &reqBody); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- } else {
- for _, c := range resp.Cookies() {
- http.SetCookie(w, c)
- }
+ resp, err := postToKratos("login", flow[0], r.Cookies(), &reqBody)
+ if err == nil {
+ err = extractError(resp.Body)
+ }
+ if err != nil {
if challenge, _ := r.Cookie("login_challenge"); challenge != nil {
- username, err := getWhoAmIFromKratos(resp.Cookies())
+ redirectTo, err := s.hydra.LoginRejectChallenge(challenge.Value, err.Error())
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
- req := &http.Request{
- Method: http.MethodPut,
- URL: &url.URL{
- Scheme: "https",
- Host: "hydra.pcloud",
- Path: "/oauth2/auth/requests/login/accept",
- RawQuery: fmt.Sprintf("login_challenge=%s", challenge.Value),
- },
- Header: map[string][]string{
- "Content-Type": []string{"text/html"},
- },
- // TODO(giolekva): user stable userid instead
- Body: io.NopCloser(strings.NewReader(fmt.Sprintf(`
-{
- "subject": "%s",
- "remember": true,
- "remember_for": 3600
-}`, username))),
- }
- client := &http.Client{
- Transport: &http.Transport{
- TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
- },
- }
- resp, err := client.Do(req)
- if err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- } else {
- io.Copy(w, resp.Body)
- }
+ http.Redirect(w, r, redirectTo, http.StatusSeeOther)
+ return
}
- // http.Redirect(w, r, "/", http.StatusSeeOther)
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
}
+ for _, c := range resp.Cookies() {
+ http.SetCookie(w, c)
+ }
+ if challenge, _ := r.Cookie("login_challenge"); challenge != nil {
+ username, err := getWhoAmIFromKratos(resp.Cookies())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ redirectTo, err := s.hydra.LoginAcceptChallenge(challenge.Value, username)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, redirectTo, http.StatusSeeOther)
+ return
+ }
+ http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
@@ -378,6 +397,56 @@
}
}
+// TODO(giolekva): verify if logged in
+func (s *Server) consent(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ challenge, ok := r.Form["consent_challenge"]
+ if !ok {
+ http.Error(w, "Consent challenge not provided", http.StatusBadRequest)
+ return
+ }
+ consent, err := s.hydra.GetConsentChallenge(challenge[0])
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html")
+ if err := s.tmpls.Consent.Execute(w, consent.RequestedScopes); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+}
+
+func (s *Server) processConsent(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ username, err := getWhoAmIFromKratos(r.Cookies())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if _, accepted := r.Form["allow"]; accepted {
+ acceptedScopes, _ := r.Form["scope"]
+ idToken := map[string]string{
+ "username": username,
+ "email": username + "@lekva.me",
+ }
+ if redirectTo, err := s.hydra.ConsentAccept(r.FormValue("consent_challenge"), acceptedScopes, idToken); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ } else {
+ http.Redirect(w, r, redirectTo, http.StatusSeeOther)
+ }
+ return
+ } else {
+ // TODO(giolekva): implement rejection logic
+ }
+}
+
func main() {
flag.Parse()
t, err := ParseTemplates(tmpls)
@@ -386,6 +455,7 @@
}
s := &Server{
kratos: *kratos,
+ hydra: NewHydraClient(*hydra),
tmpls: t,
}
log.Fatal(s.Start(*port))
diff --git a/core/auth/ui/templates/consent.html b/core/auth/ui/templates/consent.html
new file mode 100644
index 0000000..9131e99
--- /dev/null
+++ b/core/auth/ui/templates/consent.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8" />
+ <title>Consent</title>
+</head>
+<body>
+ <a href="/">whoami</a>
+ <a href="/login">login</a>
+ <a href="/logout">logout</a>
+ <a href="/registration">registration</a><br/>
+ <form action="" method="POST">
+ {{range .}}
+ <input type="checkbox" name="scope" value="{{.}}" />{{.}}<br />
+ {{end}}
+ <input type="submit" name="allow" value="Allow" />
+ <input type="submit" name="reject" value="Reject" />
+ </form>
+</body>
+</html>
+
diff --git a/core/auth/ui/templates/login.html b/core/auth/ui/templates/login.html
index 04d0435..b5f73db 100644
--- a/core/auth/ui/templates/login.html
+++ b/core/auth/ui/templates/login.html
@@ -11,7 +11,7 @@
<a href="/registration">registration</a><br/>
<form action="" method="POST">
<label for="username">Username:</label><br />
- <input type="text" name="username" /><br />
+ <input type="text" name="username" autofocus /><br />
<label for="password">Password:</label><br />
<input type="password" name="password" /><br />
<input type="hidden" name="csrf_token" value="{{.}}" /><br />
diff --git a/core/auth/ui/templates/registration.html b/core/auth/ui/templates/registration.html
index 6a10af8..9d3c0fd 100644
--- a/core/auth/ui/templates/registration.html
+++ b/core/auth/ui/templates/registration.html
@@ -11,7 +11,7 @@
<a href="/registration">registration</a><br/>
<form action="" method="POST">
<label for="username">Username:</label><br />
- <input type="text" name="username" /><br />
+ <input type="text" name="username" autofocus /><br />
<label for="password">Password:</label><br />
<input type="password" name="password" /><br />
<input type="hidden" name="csrf_token" value="{{.}}" /><br />