Exchange token and fetch userinfo

This commit is contained in:
Melon 2024-02-07 10:54:37 +00:00
parent 1f2be68470
commit 9228f6649e
Signed by: melon
GPG Key ID: 6C9D970C50D26A25
5 changed files with 304 additions and 123 deletions

View File

@ -24,6 +24,7 @@ type UserAuth struct {
type SessionData struct { type SessionData struct {
ID string ID string
DisplayName string DisplayName string
UserInfo map[string]any
} }
func (u UserAuth) IsGuest() bool { func (u UserAuth) IsGuest() bool {

View File

@ -1,6 +1,12 @@
package server package server
import ( import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"github.com/1f349/lavender/pages" "github.com/1f349/lavender/pages"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
@ -11,7 +17,12 @@ import (
"time" "time"
) )
func (h *HttpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { func (h *HttpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
if !auth.IsGuest() {
h.SafeRedirect(rw, req)
return
}
cookie, err := req.Cookie("lavender-login-name") cookie, err := req.Cookie("lavender-login-name")
if err == nil && cookie.Valid() == nil { if err == nil && cookie.Valid() == nil {
pages.RenderPageTemplate(rw, "login-memory", map[string]any{ pages.RenderPageTemplate(rw, "login-memory", map[string]any{
@ -27,7 +38,12 @@ func (h *HttpServer) loginGet(rw http.ResponseWriter, req *http.Request, _ httpr
}) })
} }
func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params) { func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
if !auth.IsGuest() {
h.SafeRedirect(rw, req)
return
}
if req.PostFormValue("not-you") == "1" { if req.PostFormValue("not-you") == "1" {
http.SetCookie(rw, &http.Cookie{ http.SetCookie(rw, &http.Cookie{
Name: "lavender-login-name", Name: "lavender-login-name",
@ -77,3 +93,79 @@ func (h *HttpServer) loginPost(rw http.ResponseWriter, req *http.Request, _ http
nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn)) nextUrl := oa2conf.AuthCodeURL(state, oauth2.SetAuthURLParam("login_name", loginUn))
http.Redirect(rw, req, nextUrl, http.StatusFound) http.Redirect(rw, req, nextUrl, http.StatusFound)
} }
func (h *HttpServer) loginCallback(rw http.ResponseWriter, req *http.Request, _ httprouter.Params, auth UserAuth) {
flowState, ok := h.flowState.Get(req.FormValue("state"))
if !ok {
http.Error(rw, "Invalid flow state", http.StatusBadRequest)
return
}
token, err := flowState.sso.OAuth2Config.Exchange(context.Background(), req.FormValue("code"), oauth2.SetAuthURLParam("redirect_uri", h.conf.BaseUrl+"/callback"))
if err != nil {
http.Error(rw, "Failed to exchange code for token", http.StatusInternalServerError)
return
}
res, err := flowState.sso.OAuth2Config.Client(context.Background(), token).Get(flowState.sso.UserInfoEndpoint)
if err != nil || res.StatusCode != 200 {
rw.WriteHeader(http.StatusInternalServerError)
if err != nil {
_, _ = rw.Write([]byte(err.Error()))
} else {
_, _ = rw.Write([]byte(res.Status))
}
return
}
defer res.Body.Close()
var userInfoJson map[string]any
if err := json.NewDecoder(res.Body).Decode(&userInfoJson); err != nil {
http.Error(rw, err.Error(), http.StatusInternalServerError)
return
}
subject, ok := userInfoJson["sub"].(string)
if !ok {
http.Error(rw, "Invalid subject", http.StatusInternalServerError)
return
}
subject += "@" + flowState.sso.Config.Namespace
displayName, ok := userInfoJson["name"].(string)
if !ok {
displayName = "Unknown Name"
}
// only continues if the above tx succeeds
auth.Data = SessionData{
ID: subject,
DisplayName: displayName,
UserInfo: userInfoJson,
}
if auth.SaveSessionData() != nil {
http.Error(rw, "Failed to save session", http.StatusInternalServerError)
return
}
if h.setLoginDataCookie(rw, auth.Data.ID) {
http.Error(rw, "Internal Server Error", http.StatusInternalServerError)
return
}
h.SafeRedirect(rw, req)
}
func (h *HttpServer) setLoginDataCookie(rw http.ResponseWriter, userId string) bool {
encryptedData, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, h.signingKey.PublicKey(), []byte(userId), []byte("login-data"))
if err != nil {
return true
}
encryptedString := base64.RawStdEncoding.EncodeToString(encryptedData)
http.SetCookie(rw, &http.Cookie{
Name: "login-data",
Value: encryptedString,
Path: "/",
Expires: time.Now().AddDate(0, 3, 0),
Secure: true,
SameSite: http.SameSiteStrictMode,
})
return false
}

View File

@ -18,6 +18,7 @@ import (
"github.com/go-oauth2/oauth2/v4/manage" "github.com/go-oauth2/oauth2/v4/manage"
"github.com/go-oauth2/oauth2/v4/server" "github.com/go-oauth2/oauth2/v4/server"
"github.com/go-oauth2/oauth2/v4/store" "github.com/go-oauth2/oauth2/v4/store"
"github.com/go-session/session"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"log" "log"
"net/http" "net/http"
@ -44,6 +45,8 @@ type flowStateData struct {
} }
func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Server { func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Server {
session.InitManager(session.SetCookieName("lavender_session"))
r := httprouter.New() r := httprouter.New()
// remove last slash from baseUrl // remove last slash from baseUrl
@ -117,8 +120,9 @@ func NewHttpServer(conf Conf, db *database.DB, signingKey mjwt.Signer) *http.Ser
r.GET("/", hs.OptionalAuthentication(hs.Home)) r.GET("/", hs.OptionalAuthentication(hs.Home))
// login // login
r.GET("/login", hs.loginGet) r.GET("/login", hs.OptionalAuthentication(hs.loginGet))
r.POST("/login", hs.loginPost) r.POST("/login", hs.OptionalAuthentication(hs.loginPost))
r.GET("/callback", hs.OptionalAuthentication(hs.loginCallback))
r.POST("/logout", hs.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) { r.POST("/logout", hs.RequireAuthentication(func(rw http.ResponseWriter, req *http.Request, params httprouter.Params, auth UserAuth) {
lNonce, ok := auth.Session.Get("action-nonce") lNonce, ok := auth.Session.Get("action-nonce")
if !ok { if !ok {

View File

@ -1,142 +1,92 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<title>Test Client</title> <title>Test Client</title>
<script> <script src="popup.js"></script>
let currentLoginPopup = null; <script>
let currentTokens = null; let currentTokens = null;
const ssoService = "http://localhost:9090"; const ssoService = "http://localhost:9090";
function updateTokenInfo(data) { POP2.init(ssoService + "/authorize", "a", "openid profile", 500, 600);
currentTokens = data.tokens;
data.tokens = {
access: "*****",
refresh: "*****",
}
document.getElementById("someTextArea").textContent = JSON.stringify(data, null, 2);
let perms = document.getElementById("somePerms");
while (perms.childNodes.length > 0) {
perms.childNodes.item(0).remove();
}
document.getElementById("tokenValues").textContent = JSON.stringify(currentTokens, null, 2);
let jwt = parseJwt(currentTokens.access); function updateTokenInfo(data) {
if (jwt.per != null) { currentTokens = data.tokens;
jwt.per.forEach(function (x) { data.tokens = {
let a = document.createElement("li"); access: "*****",
a.textContent = x; refresh: "*****",
perms.appendChild(a); }
}); document.getElementById("someTextArea").textContent = JSON.stringify(data, null, 2);
} let perms = document.getElementById("somePerms");
} while (perms.childNodes.length > 0) {
perms.childNodes.item(0).remove();
}
document.getElementById("tokenValues").textContent = JSON.stringify(currentTokens, null, 2);
window.addEventListener("message", function (event) { let jwt = parseJwt(currentTokens.access);
if (event.origin !== ssoService) return; if (jwt.per != null) {
if (isObject(event.data)) { jwt.per.forEach(function (x) {
updateTokenInfo(event.data); let a = document.createElement("li");
a.textContent = x;
perms.appendChild(a);
});
}
}
if (currentLoginPopup) currentLoginPopup.close(); function parseJwt(token) {
return; const base64Url = token.split('.')[1];
} const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
alert("Failed to log user in: the login data was probably corrupted"); const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function (c) {
}); return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join(''));
return JSON.parse(jsonPayload);
}
function parseJwt(token) { function doThisThing() {
const base64Url = token.split('.')[1]; POP2.getToken(function (token) {
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); console.log(token);
const jsonPayload = decodeURIComponent(window.atob(base64).split('').map(function (c) { });
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); }
}).join(''));
return JSON.parse(jsonPayload);
}
function isObject(obj) {
return obj != null && obj.constructor.name === "Object"
}
function popupCenterScreen(url, title, w, h, focus) { </script>
const top = (screen.availHeight - h) / 4, left = (screen.availWidth - w) / 2; <style>
const popup = openWindow(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`); :root {
if (focus === true && window.focus) popup.focus(); color-scheme: light dark;
return popup; }
}
function openWindow(url, winnm, options) { #someTextArea {
var wTop = firstAvailableValue([window.screen.availTop, window.screenY, window.screenTop, 0]); width: 400px;
var wLeft = firstAvailableValue([window.screen.availLeft, window.screenX, window.screenLeft, 0]); height: 400px;
var top = 0, left = 0; }
var result;
if ((result = /top=(\d+)/g.exec(options))) top = parseInt(result[1]);
if ((result = /left=(\d+)/g.exec(options))) left = parseInt(result[1]);
if (options) {
options = options.replace("top=" + top, "top=" + (parseInt(top) + wTop));
options = options.replace("left=" + left, "left=" + (parseInt(left) + wLeft));
w = window.open(url, winnm, options);
} else w = window.open(url, winnm);
return w;
}
function firstAvailableValue(arr) { #tokenValues {
for (var i = 0; i < arr.length; i++) width: 400px;
if (typeof arr[i] != 'undefined') height: 400px;
return arr[i]; }
} </style>
function doThisThing() {
if (currentLoginPopup) currentLoginPopup.close();
currentLoginPopup = popupCenterScreen(ssoService + '/popup?origin=' + encodeURIComponent(location.origin), 'Login with Lavender', 500, 500, false);
}
async function refreshAllTokens() {
let req = await fetch(ssoService + '/refresh', {
method: 'POST',
mode: 'cors',
cache: 'no-cache',
credentials: 'include',
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({"token": currentTokens.refresh}),
});
let reqJson = await req.json();
updateTokenInfo(reqJson);
}
</script>
<style>
#someTextArea {
width: 400px;
height: 400px;
}
#tokenValues {
width: 400px;
height: 400px;
}
</style>
</head> </head>
<body> <body>
<header> <header>
<h1>Test Client</h1> <h1>Test Client</h1>
</header> </header>
<main> <main>
<div>
<button onclick="doThisThing();">Login</button>
<button onclick="refreshAllTokens();">Refresh</button>
</div>
<div style="display:flex; gap: 2em;">
<div> <div>
<div> <button onclick="doThisThing();">Login</button>
<label for="someTextArea"></label><textarea id="someTextArea"></textarea>
</div>
<div>
<label for="tokenValues"></label><textarea id="tokenValues"></textarea>
</div>
</div> </div>
<div> <div style="display:flex; gap: 2em;">
<p>Permissions:</p> <div>
<ul id="somePerms"></ul> <div>
<label for="someTextArea"></label><textarea id="someTextArea"></textarea>
</div>
<div>
<label for="tokenValues"></label><textarea id="tokenValues"></textarea>
</div>
</div>
<div>
<p>Permissions:</p>
<ul id="somePerms"></ul>
</div>
</div> </div>
</div>
</main> </main>
</body> </body>
</html> </html>

134
test-client/popup.js Normal file
View File

@ -0,0 +1,134 @@
/* Simple OAuth 2.0 Client flow library
Author: MrMelon54, timdream
Usage:
POP2.init(client_id, scope)
Initialize the library.
redirect_uri is the current page (window.location.href).
This function should be put before Analytics so that the second click won't result a page view register.
POP2.getToken(callback)
Send access token to the callback function as the first argument.
If not logged in this triggers login popup and execute login after logged in.
Be sure to call this function in user-triggered event (such as click) to prevent popup blocker.
If not sure do use isLoggedIn() below to check first.
POP2.isLoggedIn()
boolean
*/
"use strict";
(function (w) {
const windowName = 'pop2_oauth2_login_popup';
if (window.name === windowName) {
if (
window.opener &&
window.opener.POP2
) {
if (window.location.hash.indexOf('access_token') !== -1) {
window.opener.POP2.receiveToken(
window.location.hash.replace(/^.*access_token=([^&]+).*$/, '$1'),
parseInt(window.location.hash.replace(/^.*expires_in=([^&]+).*$/, '$1'))
);
}
if (window.location.search.indexOf('error=')) {
window.opener.POP2.receiveToken('ERROR');
}
}
window.close();
}
function popupCenterScreen(url, title, w, h) {
const top = (screen.availHeight - h) / 4, left = (screen.availWidth - w) / 2;
return openWindow(url, title, `scrollbars=yes,width=${w},height=${h},top=${top},left=${left}`);
}
function openWindow(url, winnm, options) {
const wTop = firstAvailableValue([window.screen.availTop, window.screenY, window.screenTop, 0]);
const wLeft = firstAvailableValue([window.screen.availLeft, window.screenX, window.screenLeft, 0]);
let top = "0",
left = "0",
result;
if ((result = /top=(\d+)/g.exec(options))) top = parseInt(result[1]);
if ((result = /left=(\d+)/g.exec(options))) left = parseInt(result[1]);
if (options) {
options = options.replace("top=" + top, "top=" + (parseInt(top) + wTop));
options = options.replace("left=" + left, "left=" + (parseInt(left) + wLeft));
w = window.open(url, winnm, options);
} else w = window.open(url, winnm);
return w;
}
function firstAvailableValue(arr) {
for (let i = 0; i < arr.length; i++)
if (typeof arr[i] != 'undefined')
return arr[i];
}
let client_endpoint,
client_id,
scope = '',
redirect_uri = window.location.href.substr(0, window.location.href.length - window.location.hash.length).replace(/#$/, ''),
access_token,
callbackWaitForToken,
w_width = 400,
w_height = 360;
w.POP2 = {
// init
init: function (f_client_endpoint, f_client_id, f_scope, width, height) {
if (!f_client_endpoint) return false;
if (!f_client_id) return false;
client_endpoint = f_client_endpoint;
client_id = f_client_id;
if (f_scope) scope = f_scope;
if (width) w_width = width;
if (height) w_height = height;
},
// receive token from popup
receiveToken: function (token, expires_in) {
if (token !== 'ERROR') {
access_token = token;
if (callbackWaitForToken) callbackWaitForToken(access_token);
setTimeout(
function () {
access_token = undefined;
},
expires_in * 1000
);
} else if (token === false) {
callbackWaitForToken = undefined;
}
},
// boolean, indicate logged in or not
isLoggedIn: function () {
return !!access_token;
},
// pass the access token to callback
// if not logged in this triggers login popup;
// use isLoggedIn to check login first to prevent popup blocker
getToken: function (callback) {
if (!client_id || !redirect_uri || !scope) {
alert('You need init() first. Check the program flow.');
return false;
}
if (!access_token) {
callbackWaitForToken = callback;
popupCenterScreen(
client_endpoint
+ '?response_type=token'
+ '&redirect_uri=' + encodeURIComponent(redirect_uri)
+ '&scope=' + encodeURIComponent(scope)
+ '&client_id=' + encodeURIComponent(client_id),
windowName,
w_width,
w_height
);
} else {
return callback(access_token);
}
}
};
})(this);