diff --git a/go.mod b/go.mod index 8212fd59..51d6f62b 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/stretchr/testify v1.7.0 github.com/teacat/noire v1.1.0 github.com/wellington/go-libsass v0.9.2 + golang.org/x/crypto v0.0.0-20210317152858-513c2a44f670 ) replace github.com/rs/zerolog v1.20.0 => github.com/bvisness/zerolog v1.20.1-0.20210321191248-05f63bf0e9e0 diff --git a/go.sum b/go.sum index c7e9a325..f538b803 100644 --- a/go.sum +++ b/go.sum @@ -304,8 +304,9 @@ golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210317152858-513c2a44f670 h1:gzMM0EjIYiRmJI3+jBdFuoynZlpxa2JQZsolKu09BXo= +golang.org/x/crypto v0.0.0-20210317152858-513c2a44f670/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -340,8 +341,9 @@ golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974 h1:IX6qOQeG5uLjB/hjjwjedwfjND0hgjPMMyO1RoIXQNI= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -372,7 +374,9 @@ golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= diff --git a/src/auth/auth.go b/src/auth/auth.go new file mode 100644 index 00000000..0d47fe02 --- /dev/null +++ b/src/auth/auth.go @@ -0,0 +1,69 @@ +package auth + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "strconv" + "strings" + + "git.handmade.network/hmn/hmn/src/oops" + "golang.org/x/crypto/pbkdf2" +) + +type HashAlgorithm string + +const ( + PBKDF2_SHA256 = "pbkdf2_sha256" +) + +const PKBDF2KeyLength = 64 + +type HashedPassword struct { + Algorithm HashAlgorithm + Iterations int + Salt string + Hash string +} + +func ParseDjangoPasswordString(s string) (HashedPassword, error) { + pieces := strings.SplitN(s, "$", 4) + if len(pieces) < 4 { + return HashedPassword{}, oops.New(nil, "unrecognized password string format") + } + + iterations, err := strconv.Atoi(pieces[1]) + if err != nil { + return HashedPassword{}, oops.New(err, "could not parse password iterations") + } + + return HashedPassword{ + Algorithm: HashAlgorithm(pieces[0]), + Iterations: iterations, + Salt: pieces[2], + Hash: pieces[3], + }, nil +} + +func CheckPassword(password string, hashedPassword HashedPassword) (bool, error) { + switch hashedPassword.Algorithm { + case PBKDF2_SHA256: + decoded, err := base64.StdEncoding.DecodeString(hashedPassword.Hash) + if err != nil { + return false, oops.New(nil, "failed to get key length of hashed password") + } + + newHash := pbkdf2.Key( + []byte(password), + []byte(hashedPassword.Salt), + hashedPassword.Iterations, + len(decoded), + sha256.New, + ) + newHashEncoded := base64.StdEncoding.EncodeToString(newHash) + + return bytes.Equal([]byte(newHashEncoded), []byte(hashedPassword.Hash)), nil + default: + return false, oops.New(nil, "unrecognized password hash algorithm: %s", hashedPassword.Algorithm) + } +} diff --git a/src/db/db.go b/src/db/db.go index ad8fef77..16cdeeb4 100644 --- a/src/db/db.go +++ b/src/db/db.go @@ -3,11 +3,11 @@ package db import ( "context" "errors" + "fmt" "reflect" "strings" "git.handmade.network/hmn/hmn/src/config" - "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" "github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4/log/zerologadapter" @@ -61,11 +61,19 @@ func (it *StructQueryIterator) Next(dest interface{}) bool { panic(err) } + fmt.Printf("%#v\n", vals) + for i, val := range vals { field := v.Elem().Field(it.fieldIndices[i]) switch field.Kind() { case reflect.Int: field.SetInt(reflect.ValueOf(val).Int()) + case reflect.Ptr: + // TODO: I'm pretty sure we don't handle nullable ints correctly lol. Maybe this needs to be a function somehow, and recurse onto itself?? Reflection + recursion sounds like a great idea + if val != nil { + field.Set(reflect.New(field.Type().Elem())) + field.Elem().Set(reflect.ValueOf(val)) + } default: field.Set(reflect.ValueOf(val)) } @@ -82,7 +90,15 @@ func QueryToStructs(ctx context.Context, conn *pgxpool.Pool, destType interface{ var fieldIndices []int var columnNames []string - t := reflect.TypeOf(models.Project{}) + t := reflect.TypeOf(destType) + if t.Kind() == reflect.Ptr { + t = t.Elem() + } + + if t.Kind() != reflect.Struct { + return StructQueryIterator{}, oops.New(nil, "QueryToStructs requires a struct type or a pointer to a struct type") + } + for i := 0; i < t.NumField(); i++ { f := t.Field(i) if columnName := f.Tag.Get("db"); columnName != "" { diff --git a/src/logging/logging.go b/src/logging/logging.go index 42b9adc0..910eac94 100644 --- a/src/logging/logging.go +++ b/src/logging/logging.go @@ -185,8 +185,15 @@ func LogPanicValue(logger *zerolog.Logger, val interface{}, msg string) { } if err, ok := val.(error); ok { - logger.Error().Err(err).Msg(msg) + l := logger.Error().Err(err) + if _, ok := err.(*oops.Error); !ok { + l = l.Interface(zerolog.ErrorStackFieldName, oops.Trace()) + } + l.Msg(msg) } else { - logger.Error().Interface("recovered", val).Msg(msg) + logger.Error(). + Interface("recovered", val). + Interface(zerolog.ErrorStackFieldName, oops.Trace()). + Msg(msg) } } diff --git a/src/models/user.go b/src/models/user.go new file mode 100644 index 00000000..8db8b0a0 --- /dev/null +++ b/src/models/user.go @@ -0,0 +1,18 @@ +package models + +import "time" + +type User struct { + ID int `db:"id"` + + Username string `db:"username"` + Password string `db:"password"` + Email string `db:"email"` + + DateJoined time.Time `db:"date_joined"` + LastLogin *time.Time `db:"last_login"` + + IsSuperuser bool `db:"is_superuser"` + IsStaff bool `db:"is_staff"` + IsActive bool `db:"is_active"` +} diff --git a/src/oops/oops.go b/src/oops/oops.go index a660460a..d2fc3170 100644 --- a/src/oops/oops.go +++ b/src/oops/oops.go @@ -50,7 +50,15 @@ var ZerologStackMarshaler = func(err error) interface{} { } func New(wrapped error, format string, args ...interface{}) error { - trace := stack.Trace().TrimRuntime() + return &Error{ + Message: fmt.Sprintf(format, args...), + Wrapped: wrapped, + Stack: Trace(), + } +} + +func Trace() CallStack { + trace := stack.Trace().TrimRuntime()[1:] frames := make(CallStack, len(trace)) for i, call := range trace { callFrame := call.Frame() @@ -61,9 +69,5 @@ func New(wrapped error, format string, args ...interface{}) error { } } - return &Error{ - Message: fmt.Sprintf(format, args...), - Wrapped: wrapped, - Stack: frames, - } + return frames } diff --git a/src/website/requesthandling.go b/src/website/requesthandling.go index 9097c0e3..dbe69933 100644 --- a/src/website/requesthandling.go +++ b/src/website/requesthandling.go @@ -34,7 +34,11 @@ func (r *HMNRouter) GET(route string, handler HMNHandler) { r.Handle(http.MethodGet, route, handler) } -// TODO: POST, etc. +func (r *HMNRouter) POST(route string, handler HMNHandler) { + r.Handle(http.MethodPost, route, handler) +} + +// TODO: More methods func (r *HMNRouter) ServeFiles(path string, root http.FileSystem) { r.HttpRouter.ServeFiles(path, root) @@ -94,7 +98,7 @@ func (c *RequestContext) AddErrors(errs ...error) { c.Errors = append(c.Errors, errs...) } -func (c *RequestContext) AbortWithErrors(status int, errs ...error) { +func (c *RequestContext) Errored(status int, errs ...error) { c.StatusCode = status c.AddErrors(errs...) } diff --git a/src/website/routes.go b/src/website/routes.go index 19366e34..423a07fd 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -2,12 +2,16 @@ package website import ( "context" + "encoding/json" "errors" "fmt" + "io" "net/http" "strings" + "git.handmade.network/hmn/hmn/src/auth" "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/logging" "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/templates" @@ -23,8 +27,11 @@ type websiteRoutes struct { func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler { routes := &websiteRoutes{ - HMNRouter: &HMNRouter{HttpRouter: httprouter.New()}, - conn: conn, + HMNRouter: &HMNRouter{ + HttpRouter: httprouter.New(), + Wrappers: []HMNHandlerWrapper{ErrorLoggingWrapper}, + }, + conn: conn, } mainRoutes := routes.WithWrappers(routes.CommonWebsiteDataWrapper) @@ -32,6 +39,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool) http.Handler { mainRoutes.GET("/project/:id", routes.Project) mainRoutes.GET("/assets/project.css", routes.ProjectCSS) + routes.POST("/login", routes.Login) + routes.ServeFiles("/public/*filepath", http.Dir("public")) return routes @@ -120,6 +129,63 @@ func (s *websiteRoutes) ProjectCSS(c *RequestContext, p httprouter.Params) { } } +func (s *websiteRoutes) Login(c *RequestContext, p httprouter.Params) { + bodyBytes, _ := io.ReadAll(c.Req.Body) + + // TODO: Update this endpoint to give uniform responses on errors and to be resilient to timing attacks. + + var body struct { + Username string + Password string + } + err := json.Unmarshal(bodyBytes, &body) + if err != nil { + panic(err) + } + + var user models.User + err = db.QueryOneToStruct(c.Context(), s.conn, &user, "SELECT $columns FROM auth_user WHERE username = $1", body.Username) + if err != nil { + if errors.Is(err, db.ErrNoMatchingRows) { + c.StatusCode = http.StatusUnauthorized + } else { + c.Errored(http.StatusInternalServerError, oops.New(err, "failed to look up user by username")) + } + return + } + + logging.Debug().Interface("user", user).Msg("the user to check") + + hashed, err := auth.ParseDjangoPasswordString(user.Password) + if err != nil { + c.Errored(http.StatusInternalServerError, oops.New(err, "failed to parse password string")) + return + } + + passwordsMatch, err := auth.CheckPassword(body.Password, hashed) + if err != nil { + c.Errored(http.StatusInternalServerError, oops.New(err, "failed to check password against hash")) + return + } + + if passwordsMatch { + c.Body.WriteString("ur good") + } else { + c.StatusCode = http.StatusUnauthorized + c.Body.WriteString("nope") + } +} + +func ErrorLoggingWrapper(h HMNHandler) HMNHandler { + return func(c *RequestContext, p httprouter.Params) { + h(c, p) + + for _, err := range c.Errors { + c.Logger.Error().Err(err).Msg("error occurred during request") + } + } +} + func (s *websiteRoutes) CommonWebsiteDataWrapper(h HMNHandler) HMNHandler { return func(c *RequestContext, p httprouter.Params) { slug := "" @@ -130,7 +196,7 @@ func (s *websiteRoutes) CommonWebsiteDataWrapper(h HMNHandler) HMNHandler { dbProject, err := FetchProjectBySlug(c.Context(), s.conn, slug) if err != nil { - c.AbortWithErrors(http.StatusInternalServerError, oops.New(err, "failed to fetch current project")) + c.Errored(http.StatusInternalServerError, oops.New(err, "failed to fetch current project")) return }