Get login working

This commit is contained in:
Ben Visness 2021-03-27 16:10:11 -05:00
parent 56cd737203
commit cdfe02726c
8 changed files with 219 additions and 68 deletions

79
src/auth/session.go Normal file
View File

@ -0,0 +1,79 @@
package auth
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"io"
"net/http"
"time"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4/pgxpool"
)
const SessionCookieName = "HMNSession"
const sessionDuration = time.Hour * 24 * 14
func makeSessionId() string {
idBytes := make([]byte, 40)
_, err := io.ReadFull(rand.Reader, idBytes)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(idBytes)[:40]
}
var ErrNoSession = errors.New("no session found")
func GetSession(ctx context.Context, conn *pgxpool.Pool, id string) (*models.Session, error) {
var sess models.Session
err := db.QueryOneToStruct(ctx, conn, &sess, "SELECT $columns FROM sessions WHERE id = $1", id)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
return nil, ErrNoSession
} else {
return nil, oops.New(err, "failed to get session")
}
}
return &sess, nil
}
func CreateSession(ctx context.Context, conn *pgxpool.Pool, username string) (*models.Session, error) {
session := models.Session{
ID: makeSessionId(),
Username: username,
ExpiresAt: time.Now().Add(sessionDuration),
}
_, err := conn.Exec(ctx,
"INSERT INTO sessions (id, username, expires_at) VALUES ($1, $2, $3)",
session.ID, session.Username, session.ExpiresAt,
)
if err != nil {
return nil, oops.New(err, "failed to persist session")
}
return &session, nil
}
func NewSessionCookie(session *models.Session) *http.Cookie {
return &http.Cookie{
Name: SessionCookieName,
Value: session.ID,
Domain: config.Config.Auth.CookieDomain,
Expires: time.Now().Add(sessionDuration),
Secure: config.Config.Auth.CookieSecure,
HttpOnly: true,
SameSite: http.SameSiteDefaultMode,
}
}

View File

@ -1,49 +0,0 @@
package auth
import (
"encoding/json"
"net/http"
"git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/oops"
)
const AuthCookieName = "HMNToken"
type Token struct {
Username string `json:"username"`
}
// TODO: ENCRYPT THIS
func EncodeToken(token Token) string {
tokenBytes, _ := json.Marshal(token)
return string(tokenBytes)
}
func DecodeToken(tokenStr string) (Token, error) {
var token Token
err := json.Unmarshal([]byte(tokenStr), &token)
if err != nil {
// TODO: Is this worthy of an oops error, or should this just be a value handled silently by code?
return Token{}, oops.New(err, "failed to unmarshal token")
}
return token, nil
}
func NewAuthCookie(username string) *http.Cookie {
return &http.Cookie{
Name: AuthCookieName,
Value: EncodeToken(Token{
Username: username,
}),
Domain: config.Config.CookieDomain,
// TODO: Path?
// Secure: true,
HttpOnly: true,
SameSite: http.SameSiteDefaultMode,
}
}

View File

@ -15,12 +15,11 @@ const (
) )
type HMNConfig struct { type HMNConfig struct {
Env Environment Env Environment
Addr string Addr string
BaseUrl string BaseUrl string
Postgres PostgresConfig Postgres PostgresConfig
CookieDomain string Auth AuthConfig
TokenSecret string
} }
type PostgresConfig struct { type PostgresConfig struct {
@ -32,6 +31,11 @@ type PostgresConfig struct {
LogLevel pgx.LogLevel LogLevel pgx.LogLevel
} }
type AuthConfig struct {
CookieDomain string
CookieSecure bool
}
func (info PostgresConfig) DSN() string { func (info PostgresConfig) DSN() string {
return fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s", info.User, info.Password, info.Hostname, info.Port, info.DbName) return fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s", info.User, info.Password, info.Hostname, info.Port, info.DbName)
} }

View File

@ -246,7 +246,7 @@ func MakeMigration(name, description string) {
safeVersion := strings.ReplaceAll(types.MigrationVersion(now).String(), ":", "") safeVersion := strings.ReplaceAll(types.MigrationVersion(now).String(), ":", "")
filename := fmt.Sprintf("%v_%v.go", safeVersion, name) filename := fmt.Sprintf("%v_%v.go", safeVersion, name)
path := filepath.Join("migration", "migrations", filename) path := filepath.Join("src", "migration", "migrations", filename)
err := os.WriteFile(path, []byte(result), 0644) err := os.WriteFile(path, []byte(result), 0644)
if err != nil { if err != nil {

View File

@ -0,0 +1,45 @@
package migrations
import (
"context"
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
"github.com/jackc/pgx/v4"
)
func init() {
registerMigration(AddSessionTable{})
}
type AddSessionTable struct{}
func (m AddSessionTable) Version() types.MigrationVersion {
return types.MigrationVersion(time.Date(2021, 3, 26, 3, 38, 34, 0, time.UTC))
}
func (m AddSessionTable) Name() string {
return "AddSessionTable"
}
func (m AddSessionTable) Description() string {
return "Adds a session table to replace the Django session table"
}
func (m AddSessionTable) Up(tx pgx.Tx) error {
_, err := tx.Exec(context.Background(), `
CREATE TABLE sessions (
id VARCHAR(40) PRIMARY KEY,
username VARCHAR(150) NOT NULL,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL
);
`)
return err
}
func (m AddSessionTable) Down(tx pgx.Tx) error {
_, err := tx.Exec(context.Background(), `
DROP TABLE sessions;
`)
return err
}

9
src/models/session.go Normal file
View File

@ -0,0 +1,9 @@
package models
import "time"
type Session struct {
ID string `db:"id"`
Username string `db:"username"`
ExpiresAt time.Time `db:"expires_at"`
}

View File

@ -67,6 +67,8 @@ type RequestContext struct {
rw http.ResponseWriter rw http.ResponseWriter
currentProject *models.Project currentProject *models.Project
currentUser *models.User
// currentMember *models.Member
} }
func newRequestContext(rw http.ResponseWriter, req *http.Request, route string) *RequestContext { func newRequestContext(rw http.ResponseWriter, req *http.Request, route string) *RequestContext {

View File

@ -45,6 +45,16 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler {
} }
func (s *websiteRoutes) getBaseData(c *RequestContext) templates.BaseData { func (s *websiteRoutes) getBaseData(c *RequestContext) templates.BaseData {
var templateUser *templates.User
if c.currentUser != nil {
templateUser = &templates.User{
Username: c.currentUser.Username,
Email: c.currentUser.Email,
IsSuperuser: c.currentUser.IsSuperuser,
IsStaff: c.currentUser.IsStaff,
}
}
return templates.BaseData{ return templates.BaseData{
Project: templates.Project{ Project: templates.Project{
Name: c.currentProject.Name, Name: c.currentProject.Name,
@ -58,6 +68,7 @@ func (s *websiteRoutes) getBaseData(c *RequestContext) templates.BaseData {
HasWiki: true, HasWiki: true,
HasLibrary: true, HasLibrary: true,
}, },
User: templateUser,
Theme: "dark", Theme: "dark",
} }
} }
@ -171,8 +182,13 @@ func (s *websiteRoutes) Login(c *RequestContext, p httprouter.Params) {
} }
if passwordsMatch { if passwordsMatch {
logging.Debug().Str("cookie", auth.NewAuthCookie(username).String()).Msg("logged in") session, err := auth.CreateSession(c.Context(), s.conn, username)
c.SetCookie(auth.NewAuthCookie(username)) if err != nil {
c.Errored(http.StatusInternalServerError, oops.New(err, "failed to create session"))
return
}
c.SetCookie(auth.NewSessionCookie(session))
c.Redirect(redirect, http.StatusSeeOther) c.Redirect(redirect, http.StatusSeeOther)
return return
} else { } else {
@ -193,20 +209,65 @@ func ErrorLoggingWrapper(h HMNHandler) HMNHandler {
func (s *websiteRoutes) CommonWebsiteDataWrapper(h HMNHandler) HMNHandler { func (s *websiteRoutes) CommonWebsiteDataWrapper(h HMNHandler) HMNHandler {
return func(c *RequestContext, p httprouter.Params) { return func(c *RequestContext, p httprouter.Params) {
slug := "" // get project
hostParts := strings.SplitN(c.Req.Host, ".", 3) {
if len(hostParts) >= 3 { slug := ""
slug = hostParts[0] hostParts := strings.SplitN(c.Req.Host, ".", 3)
if len(hostParts) >= 3 {
slug = hostParts[0]
}
dbProject, err := FetchProjectBySlug(c.Context(), s.conn, slug)
if err != nil {
c.Errored(http.StatusInternalServerError, oops.New(err, "failed to fetch current project"))
return
}
c.currentProject = dbProject
} }
dbProject, err := FetchProjectBySlug(c.Context(), s.conn, slug) sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
if err != nil { if err == nil {
c.Errored(http.StatusInternalServerError, oops.New(err, "failed to fetch current project")) user, err := s.getCurrentUserAndMember(c.Context(), sessionCookie.Value)
return if err != nil {
} c.Errored(http.StatusInternalServerError, oops.New(err, "failed to get current user and member"))
return
}
c.currentProject = dbProject c.currentUser = user
}
// http.ErrNoCookie is the only error Cookie ever returns, so no further handling to do here.
h(c, p) h(c, p)
} }
} }
// Given a session id, fetches user and member data from the database. Will return nil for
// both if neither can be found, and will only return an error if it's serious.
//
// TODO: actually return members :)
func (s *websiteRoutes) getCurrentUserAndMember(ctx context.Context, sessionId string) (*models.User, error) {
session, err := auth.GetSession(ctx, s.conn, sessionId)
if err != nil {
if errors.Is(err, auth.ErrNoSession) {
return nil, nil
} else {
return nil, oops.New(err, "failed to get current session")
}
}
var user models.User
err = db.QueryOneToStruct(ctx, s.conn, &user, "SELECT $columns FROM auth_user WHERE username = $1", session.Username)
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
logging.Debug().Str("username", session.Username).Msg("returning no current user for this request because the user for the session couldn't be found")
return nil, nil // user was deleted or something
} else {
return nil, oops.New(err, "failed to get user for session")
}
}
// TODO: Also get the member model
return &user, nil
}