feat: fully implemented oauth2 authentication and cookie sessions

This commit is contained in:
Tine 2024-02-11 23:48:37 +01:00
parent 130a068a7d
commit 0a323c79e6
Signed by: mentos1386
SSH key fingerprint: SHA256:MNtTsLbihYaWF8j1fkOHfkKNlnN1JQfxEU/rBU8nCGw
11 changed files with 209 additions and 5 deletions

View file

@ -44,11 +44,14 @@ func main() {
r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.FileServer(http.FS(static.Static))))
r.HandleFunc("/", h.Index).Methods("GET")
r.HandleFunc("/settings", h.Settings).Methods("GET")
// Authenticated routes
r.HandleFunc("/settings", h.Authenticated(h.Settings)).Methods("GET")
// OAuth2
r.HandleFunc("/oauth2/login", h.OAuth2LoginGET).Methods("GET")
r.HandleFunc("/oauth2/callback", h.OAuth2CallbackGET).Methods("GET")
r.HandleFunc("/oauth2/logout", h.Authenticated(h.OAuth2LogoutGET)).Methods("GET")
log.Println("Server started on", config.PORT)
log.Fatal(http.ListenAndServe(":"+config.PORT, r))

View file

@ -16,3 +16,4 @@ OAUTH2_SCOPES=openid,profile,email
OAUTH2_ENDPOINT_TOKEN_URL=https://your_oauth2_provider/token
OAUTH2_ENDPOINT_AUTH_URL=https://your_oauth2_provider/auth
OAUTH2_ENDPOINT_USER_INFO_URL=https://your_oauth2_provider/userinfo
OAUTH2_ENDPOINT_USER_INFO_URL=https://your_oauth2_provider/logout

View file

@ -16,6 +16,7 @@ primary_region = 'waw'
OAUTH2_ENDPOINT_TOKEN_URL = 'https://id.tjo.space/application/o/token/'
OAUTH2_ENDPOINT_AUTH_URL = 'https://id.tjo.space/application/o/authorize/'
OAUTH2_ENDPOINT_USER_INFO_URL = 'https://id.tjo.space/application/o/userinfo/'
OAUTH2_ENDPOINT_LOGOUT_URL = 'https://id.tjo.space/application/o/zdravko-development/end-session/'
[processes]
server = "server"

2
go.mod
View file

@ -23,6 +23,8 @@ require (
github.com/golang/mock v1.6.0 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/gorilla/sessions v1.2.2 // indirect
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 // indirect
github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect

4
go.sum
View file

@ -972,6 +972,10 @@ github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.2 h1:lqzMYz6bOfvn2WriPUjNByzeXIlVzURcPmgMczkmTjY=
github.com/gorilla/sessions v1.2.2/go.mod h1:ePLdVu+jbEgHH+KWw8I1z2wqd0BAdAQh/8LRvBeoNcQ=
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw=
github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y=
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=

View file

@ -19,6 +19,7 @@ type Config struct {
OAUTH2_ENDPOINT_TOKEN_URL string
OAUTH2_ENDPOINT_AUTH_URL string
OAUTH2_ENDPOINT_USER_INFO_URL string
OAUTH2_ENDPOINT_LOGOUT_URL string
}
func getEnv(key, fallback string) string {
@ -49,5 +50,6 @@ func NewConfig() *Config {
OAUTH2_ENDPOINT_TOKEN_URL: getEnvRequired("OAUTH2_ENDPOINT_TOKEN_URL"),
OAUTH2_ENDPOINT_AUTH_URL: getEnvRequired("OAUTH2_ENDPOINT_AUTH_URL"),
OAUTH2_ENDPOINT_USER_INFO_URL: getEnvRequired("OAUTH2_ENDPOINT_USER_INFO_URL"),
OAUTH2_ENDPOINT_LOGOUT_URL: getEnvRequired("OAUTH2_ENDPOINT_LOGOUT_URL"),
}
}

View file

@ -3,6 +3,7 @@ package handlers
import (
"code.tjo.space/mentos1386/zdravko/internal"
"code.tjo.space/mentos1386/zdravko/internal/models/query"
"github.com/gorilla/sessions"
"gorm.io/gorm"
)
@ -10,8 +11,12 @@ type BaseHandler struct {
db *gorm.DB
query *query.Query
config *internal.Config
store *sessions.CookieStore
}
func NewBaseHandler(db *gorm.DB, q *query.Query, config *internal.Config) *BaseHandler {
return &BaseHandler{db, q, config}
store := sessions.NewCookieStore([]byte(config.SESSION_SECRET))
return &BaseHandler{db, q, config, store}
}

View file

@ -2,6 +2,7 @@ package handlers
import (
"context"
"encoding/json"
"io"
"net/http"
@ -9,6 +10,11 @@ import (
"golang.org/x/oauth2"
)
type UserInfo struct {
Sub string `json:"sub"`
Email string `json:"email"`
}
func newOAuth2(config *internal.Config) *oauth2.Config {
return &oauth2.Config{
ClientID: config.OAUTH2_CLIENT_ID,
@ -22,6 +28,40 @@ func newOAuth2(config *internal.Config) *oauth2.Config {
}
}
func (h *BaseHandler) AuthenticatedUserToOAuth2Token(user *AuthenticatedUser) *oauth2.Token {
return &oauth2.Token{
AccessToken: user.OAuth2AccessToken,
TokenType: user.OAuth2TokenType,
RefreshToken: user.OAuth2RefreshToken,
Expiry: user.OAuth2Expiry,
}
}
func (h *BaseHandler) RefreshToken(w http.ResponseWriter, r *http.Request, user *AuthenticatedUser) (*AuthenticatedUser, error) {
tok := h.AuthenticatedUserToOAuth2Token(user)
conf := newOAuth2(h.config)
refreshed, err := conf.TokenSource(context.Background(), tok).Token()
if err != nil {
return nil, err
}
refreshedUser := &AuthenticatedUser{
ID: user.ID,
Email: user.Email,
OAuth2AccessToken: refreshed.AccessToken,
OAuth2RefreshToken: refreshed.RefreshToken,
OAuth2TokenType: refreshed.TokenType,
OAuth2Expiry: refreshed.Expiry,
}
err = h.SetAuthenticatedUserForRequest(w, r, refreshedUser)
if err != nil {
return nil, err
}
return refreshedUser, nil
}
func (h *BaseHandler) OAuth2LoginGET(w http.ResponseWriter, r *http.Request) {
conf := newOAuth2(h.config)
@ -54,10 +94,41 @@ func (h *BaseHandler) OAuth2CallbackGET(w http.ResponseWriter, r *http.Request)
http.Error(w, err.Error(), http.StatusInternalServerError)
}
_, err = w.Write(body)
var userInfo UserInfo
err = json.Unmarshal(body, &userInfo)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = h.SetAuthenticatedUserForRequest(w, r, &AuthenticatedUser{
ID: userInfo.Sub,
Email: userInfo.Email,
OAuth2AccessToken: tok.AccessToken,
OAuth2RefreshToken: tok.RefreshToken,
OAuth2TokenType: tok.TokenType,
OAuth2Expiry: tok.Expiry,
})
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/settings", http.StatusTemporaryRedirect)
}
func (h *BaseHandler) OAuth2LogoutGET(w http.ResponseWriter, r *http.Request, user *AuthenticatedUser) {
tok := h.AuthenticatedUserToOAuth2Token(user)
client := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(tok))
_, err := client.Get(h.config.OAUTH2_ENDPOINT_USER_INFO_URL)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = h.ClearAuthenticatedUserForRequest(w, r)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
}

View file

@ -0,0 +1,111 @@
package handlers
import (
"context"
"fmt"
"net/http"
"time"
)
const sessionName = "zdravko-hey"
type AuthenticatedUser struct {
ID string
Email string
OAuth2AccessToken string
OAuth2RefreshToken string
OAuth2TokenType string
OAuth2Expiry time.Time
}
type authenticatedUserKeyType string
const authenticatedUserKey authenticatedUserKeyType = "authenticatedUser"
func WithUser(ctx context.Context, user *AuthenticatedUser) context.Context {
return context.WithValue(ctx, authenticatedUserKey, user)
}
func GetUser(ctx context.Context) *AuthenticatedUser {
user, ok := ctx.Value(authenticatedUserKey).(*AuthenticatedUser)
if !ok {
return nil
}
return user
}
func (h *BaseHandler) GetAuthenticatedUserForRequest(r *http.Request) (*AuthenticatedUser, error) {
session, err := h.store.Get(r, sessionName)
if err != nil {
return nil, err
}
if session.IsNew {
return nil, fmt.Errorf("session is nil")
}
expiry, err := time.Parse(time.RFC3339, session.Values["oauth2_expiry"].(string))
if err != nil {
return nil, err
}
user := &AuthenticatedUser{
ID: session.Values["id"].(string),
Email: session.Values["email"].(string),
OAuth2AccessToken: session.Values["oauth2_access_token"].(string),
OAuth2RefreshToken: session.Values["oauth2_refresh_token"].(string),
OAuth2TokenType: session.Values["oauth2_token_type"].(string),
OAuth2Expiry: expiry,
}
return user, nil
}
func (h *BaseHandler) SetAuthenticatedUserForRequest(w http.ResponseWriter, r *http.Request, user *AuthenticatedUser) error {
session, err := h.store.Get(r, sessionName)
if err != nil {
return err
}
session.Values["id"] = user.ID
session.Values["email"] = user.Email
session.Values["oauth2_access_token"] = user.OAuth2AccessToken
session.Values["oauth2_refresh_token"] = user.OAuth2RefreshToken
session.Values["oauth2_token_type"] = user.OAuth2TokenType
session.Values["oauth2_expiry"] = user.OAuth2Expiry.Format(time.RFC3339)
err = h.store.Save(r, w, session)
if err != nil {
return err
}
return nil
}
func (h *BaseHandler) ClearAuthenticatedUserForRequest(w http.ResponseWriter, r *http.Request) error {
session, err := h.store.Get(r, sessionName)
if err != nil {
return err
}
session.Options.MaxAge = -1
err = h.store.Save(r, w, session)
if err != nil {
return err
}
return nil
}
type AuthenticatedHandler func(http.ResponseWriter, *http.Request, *AuthenticatedUser)
func (h *BaseHandler) Authenticated(next AuthenticatedHandler) func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
user, err := h.GetAuthenticatedUserForRequest(r)
if err != nil {
http.Redirect(w, r, "/oauth2/login", http.StatusTemporaryRedirect)
return
}
if user.OAuth2Expiry.Before(time.Now()) {
user, err = h.RefreshToken(w, r, user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
next(w, r, user)
}
}

View file

@ -7,7 +7,7 @@ import (
"code.tjo.space/mentos1386/zdravko/web/templates"
)
func (h *BaseHandler) Settings(w http.ResponseWriter, r *http.Request) {
func (h *BaseHandler) Settings(w http.ResponseWriter, r *http.Request, user *AuthenticatedUser) {
ts, err := template.ParseFS(templates.Templates,
"components/base.tmpl",
"pages/settings.tmpl",
@ -17,7 +17,7 @@ func (h *BaseHandler) Settings(w http.ResponseWriter, r *http.Request) {
return
}
err = ts.ExecuteTemplate(w, "base", nil)
err = ts.ExecuteTemplate(w, "base", user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}

View file

@ -2,4 +2,8 @@
{{define "main"}}
<h1>The settings!</h1>
<p>You are logged in as {{.Email}}.</p>
<p>Your id is {{.ID}}.</p>
<p>Your access expieres at {{.OAuth2Expiry}}.</p>
<a href="/oauth2/logout">Logout</a>
{{end}}