From cdfe02726c3d6b47d34e46f9f125e5c9198f5df8 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Sat, 27 Mar 2021 16:10:11 -0500 Subject: [PATCH] Get login working --- src/auth/session.go | 79 +++++++++++++++++ src/auth/token.go | 49 ----------- src/config/types.go | 16 ++-- src/migration/migration.go | 2 +- .../2021-03-26T033834Z_AddSessionTable.go | 45 ++++++++++ src/models/session.go | 9 ++ src/website/requesthandling.go | 2 + src/website/routes.go | 85 ++++++++++++++++--- 8 files changed, 219 insertions(+), 68 deletions(-) create mode 100644 src/auth/session.go delete mode 100644 src/auth/token.go create mode 100644 src/migration/migrations/2021-03-26T033834Z_AddSessionTable.go create mode 100644 src/models/session.go diff --git a/src/auth/session.go b/src/auth/session.go new file mode 100644 index 00000000..fb8e1307 --- /dev/null +++ b/src/auth/session.go @@ -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, + } +} diff --git a/src/auth/token.go b/src/auth/token.go deleted file mode 100644 index c204cbb2..00000000 --- a/src/auth/token.go +++ /dev/null @@ -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, - } -} diff --git a/src/config/types.go b/src/config/types.go index 692c4d77..c075d461 100644 --- a/src/config/types.go +++ b/src/config/types.go @@ -15,12 +15,11 @@ const ( ) type HMNConfig struct { - Env Environment - Addr string - BaseUrl string - Postgres PostgresConfig - CookieDomain string - TokenSecret string + Env Environment + Addr string + BaseUrl string + Postgres PostgresConfig + Auth AuthConfig } type PostgresConfig struct { @@ -32,6 +31,11 @@ type PostgresConfig struct { LogLevel pgx.LogLevel } +type AuthConfig struct { + CookieDomain string + CookieSecure bool +} + 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) } diff --git a/src/migration/migration.go b/src/migration/migration.go index 509f30e4..4eb0bfd7 100644 --- a/src/migration/migration.go +++ b/src/migration/migration.go @@ -246,7 +246,7 @@ func MakeMigration(name, description string) { safeVersion := strings.ReplaceAll(types.MigrationVersion(now).String(), ":", "") 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) if err != nil { diff --git a/src/migration/migrations/2021-03-26T033834Z_AddSessionTable.go b/src/migration/migrations/2021-03-26T033834Z_AddSessionTable.go new file mode 100644 index 00000000..8caba7d0 --- /dev/null +++ b/src/migration/migrations/2021-03-26T033834Z_AddSessionTable.go @@ -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 +} diff --git a/src/models/session.go b/src/models/session.go new file mode 100644 index 00000000..578d421b --- /dev/null +++ b/src/models/session.go @@ -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"` +} diff --git a/src/website/requesthandling.go b/src/website/requesthandling.go index ef65f9e0..85f9fc3b 100644 --- a/src/website/requesthandling.go +++ b/src/website/requesthandling.go @@ -67,6 +67,8 @@ type RequestContext struct { rw http.ResponseWriter currentProject *models.Project + currentUser *models.User + // currentMember *models.Member } func newRequestContext(rw http.ResponseWriter, req *http.Request, route string) *RequestContext { diff --git a/src/website/routes.go b/src/website/routes.go index 1210e53a..4201fd6d 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -45,6 +45,16 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler { } 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{ Project: templates.Project{ Name: c.currentProject.Name, @@ -58,6 +68,7 @@ func (s *websiteRoutes) getBaseData(c *RequestContext) templates.BaseData { HasWiki: true, HasLibrary: true, }, + User: templateUser, Theme: "dark", } } @@ -171,8 +182,13 @@ func (s *websiteRoutes) Login(c *RequestContext, p httprouter.Params) { } if passwordsMatch { - logging.Debug().Str("cookie", auth.NewAuthCookie(username).String()).Msg("logged in") - c.SetCookie(auth.NewAuthCookie(username)) + session, err := auth.CreateSession(c.Context(), s.conn, 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) return } else { @@ -193,20 +209,65 @@ func ErrorLoggingWrapper(h HMNHandler) HMNHandler { func (s *websiteRoutes) CommonWebsiteDataWrapper(h HMNHandler) HMNHandler { return func(c *RequestContext, p httprouter.Params) { - slug := "" - hostParts := strings.SplitN(c.Req.Host, ".", 3) - if len(hostParts) >= 3 { - slug = hostParts[0] + // get project + { + slug := "" + 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) - if err != nil { - c.Errored(http.StatusInternalServerError, oops.New(err, "failed to fetch current project")) - return - } + sessionCookie, err := c.Req.Cookie(auth.SessionCookieName) + if err == nil { + user, err := s.getCurrentUserAndMember(c.Context(), sessionCookie.Value) + 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) } } + +// 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 +}