After this commit, dev auth would start working with the `REMARK_URL` hostname instead of the previously hardcoded 127.0.0.1. Breaks development setup where `REMARK_URL` was set to a non-standard value and dev auth was running on 127.0.0.1 and working, as, after that change, it would stop working.
245 lines
7.0 KiB
Go
245 lines
7.0 KiB
Go
package provider
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-pkgz/rest"
|
|
"github.com/golang-jwt/jwt"
|
|
"golang.org/x/oauth2"
|
|
|
|
"github.com/go-pkgz/auth/logger"
|
|
"github.com/go-pkgz/auth/token"
|
|
)
|
|
|
|
// Oauth2Handler implements /login, /callback and /logout handlers from aouth2 flow
|
|
type Oauth2Handler struct {
|
|
Params
|
|
|
|
// all of these fields specific to particular oauth2 provider
|
|
name string
|
|
infoURL string
|
|
endpoint oauth2.Endpoint
|
|
scopes []string
|
|
mapUser func(UserData, []byte) token.User // map info from InfoURL to User
|
|
conf oauth2.Config
|
|
}
|
|
|
|
// Params to make initialized and ready to use provider
|
|
type Params struct {
|
|
logger.L
|
|
URL string
|
|
JwtService TokenService
|
|
Cid string
|
|
Csecret string
|
|
Issuer string
|
|
AvatarSaver AvatarSaver
|
|
|
|
Port int // relevant for providers supporting port customization, for example dev oauth2
|
|
Host string // relevant for providers supporting host customization, for example dev oauth2
|
|
}
|
|
|
|
// UserData is type for user information returned from oauth2 providers /info API method
|
|
type UserData map[string]interface{}
|
|
|
|
// Value returns value for key or empty string if not found
|
|
func (u UserData) Value(key string) string {
|
|
// json.Unmarshal converts json "null" value to go's "nil", in this case return empty string
|
|
if val, ok := u[key]; ok && val != nil {
|
|
return fmt.Sprintf("%v", val)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// initOauth2Handler makes oauth2 handler for given provider
|
|
func initOauth2Handler(p Params, service Oauth2Handler) Oauth2Handler {
|
|
if p.L == nil {
|
|
p.L = logger.NoOp
|
|
}
|
|
p.Logf("[INFO] init oauth2 service %s", service.name)
|
|
service.Params = p
|
|
service.conf = oauth2.Config{
|
|
ClientID: service.Cid,
|
|
ClientSecret: service.Csecret,
|
|
Scopes: service.scopes,
|
|
Endpoint: service.endpoint,
|
|
}
|
|
|
|
p.Logf("[DEBUG] created %s oauth2, id=%s, redir=%s, endpoint=%s",
|
|
service.name, service.Cid, service.makeRedirURL("/{route}/"+service.name+"/"), service.endpoint)
|
|
return service
|
|
}
|
|
|
|
// Name returns provider name
|
|
func (p Oauth2Handler) Name() string { return p.name }
|
|
|
|
// LoginHandler - GET /login?from=redirect-back-url&[site|aud]=siteID&session=1&noava=1
|
|
func (p Oauth2Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
|
|
|
p.Logf("[DEBUG] login with %s", p.Name())
|
|
// make state (random) and store in session
|
|
state, err := randToken()
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to make oauth2 state")
|
|
return
|
|
}
|
|
|
|
cid, err := randToken()
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to make claim's id")
|
|
return
|
|
}
|
|
|
|
aud := r.URL.Query().Get("site") // legacy, for back compat
|
|
if aud == "" {
|
|
aud = r.URL.Query().Get("aud")
|
|
}
|
|
|
|
claims := token.Claims{
|
|
Handshake: &token.Handshake{
|
|
State: state,
|
|
From: r.URL.Query().Get("from"),
|
|
},
|
|
SessionOnly: r.URL.Query().Get("session") != "" && r.URL.Query().Get("session") != "0",
|
|
StandardClaims: jwt.StandardClaims{
|
|
Id: cid,
|
|
Audience: aud,
|
|
ExpiresAt: time.Now().Add(30 * time.Minute).Unix(),
|
|
NotBefore: time.Now().Add(-1 * time.Minute).Unix(),
|
|
},
|
|
NoAva: r.URL.Query().Get("noava") == "1",
|
|
}
|
|
|
|
if _, err := p.JwtService.Set(w, claims); err != nil {
|
|
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to set token")
|
|
return
|
|
}
|
|
|
|
// setting RedirectURL to rootURL/routingPath/provider/callback
|
|
// e.g. http://localhost:8080/auth/github/callback
|
|
p.conf.RedirectURL = p.makeRedirURL(r.URL.Path)
|
|
|
|
// return login url
|
|
loginURL := p.conf.AuthCodeURL(state)
|
|
p.Logf("[DEBUG] login url %s, claims=%+v", loginURL, claims)
|
|
|
|
http.Redirect(w, r, loginURL, http.StatusFound)
|
|
}
|
|
|
|
// AuthHandler fills user info and redirects to "from" url. This is callback url redirected locally by browser
|
|
// GET /callback
|
|
func (p Oauth2Handler) AuthHandler(w http.ResponseWriter, r *http.Request) {
|
|
oauthClaims, _, err := p.JwtService.Get(r)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to get token")
|
|
return
|
|
}
|
|
|
|
if oauthClaims.Handshake == nil {
|
|
rest.SendErrorJSON(w, r, p.L, http.StatusForbidden, nil, "invalid handshake token")
|
|
return
|
|
}
|
|
|
|
retrievedState := oauthClaims.Handshake.State
|
|
if retrievedState == "" || retrievedState != r.URL.Query().Get("state") {
|
|
rest.SendErrorJSON(w, r, p.L, http.StatusForbidden, nil, "unexpected state")
|
|
return
|
|
}
|
|
|
|
p.conf.RedirectURL = p.makeRedirURL(r.URL.Path)
|
|
|
|
p.Logf("[DEBUG] token with state %s", retrievedState)
|
|
tok, err := p.conf.Exchange(context.Background(), r.URL.Query().Get("code"))
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "exchange failed")
|
|
return
|
|
}
|
|
|
|
client := p.conf.Client(context.Background(), tok)
|
|
uinfo, err := client.Get(p.infoURL)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, p.L, http.StatusServiceUnavailable, err, "failed to get client info")
|
|
return
|
|
}
|
|
|
|
defer func() {
|
|
if e := uinfo.Body.Close(); e != nil {
|
|
p.Logf("[WARN] failed to close response body, %s", e)
|
|
}
|
|
}()
|
|
|
|
data, err := io.ReadAll(uinfo.Body)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to read user info")
|
|
return
|
|
}
|
|
|
|
jData := map[string]interface{}{}
|
|
if e := json.Unmarshal(data, &jData); e != nil {
|
|
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to unmarshal user info")
|
|
return
|
|
}
|
|
p.Logf("[DEBUG] got raw user info %+v", jData)
|
|
|
|
u := p.mapUser(jData, data)
|
|
if oauthClaims.NoAva {
|
|
u.Picture = "" // reset picture on no avatar request
|
|
}
|
|
u, err = setAvatar(p.AvatarSaver, u, client)
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to save avatar to proxy")
|
|
return
|
|
}
|
|
|
|
cid, err := randToken()
|
|
if err != nil {
|
|
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to make claim's id")
|
|
return
|
|
}
|
|
claims := token.Claims{
|
|
User: &u,
|
|
StandardClaims: jwt.StandardClaims{
|
|
Issuer: p.Issuer,
|
|
Id: cid,
|
|
Audience: oauthClaims.Audience,
|
|
},
|
|
SessionOnly: oauthClaims.SessionOnly,
|
|
NoAva: oauthClaims.NoAva,
|
|
}
|
|
|
|
if _, err = p.JwtService.Set(w, claims); err != nil {
|
|
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to set token")
|
|
return
|
|
}
|
|
|
|
p.Logf("[DEBUG] user info %+v", u)
|
|
|
|
// redirect to back url if presented in login query params
|
|
if oauthClaims.Handshake != nil && oauthClaims.Handshake.From != "" {
|
|
http.Redirect(w, r, oauthClaims.Handshake.From, http.StatusTemporaryRedirect)
|
|
return
|
|
}
|
|
rest.RenderJSON(w, &u)
|
|
}
|
|
|
|
// LogoutHandler - GET /logout
|
|
func (p Oauth2Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
|
if _, _, err := p.JwtService.Get(r); err != nil {
|
|
rest.SendErrorJSON(w, r, p.L, http.StatusForbidden, err, "logout not allowed")
|
|
return
|
|
}
|
|
p.JwtService.Reset(w)
|
|
}
|
|
|
|
func (p Oauth2Handler) makeRedirURL(path string) string {
|
|
elems := strings.Split(path, "/")
|
|
newPath := strings.Join(elems[:len(elems)-1], "/")
|
|
|
|
return strings.TrimSuffix(p.URL, "/") + strings.TrimSuffix(newPath, "/") + urlCallbackSuffix
|
|
}
|