From c3fded44929580f12734d287327d7e757697dc26 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn Date: Sat, 15 Aug 2026 10:48:29 -0500 Subject: [PATCH] feat: we made it better...aka i did too many things and don't remember --- go.mod | 4 + go.sum | 44 ++ internal/auth/auth.go | 77 ++++ internal/auth/auth_test.go | 21 + internal/database/chirps.sql.go | 61 +++ internal/database/models.go | 17 +- internal/database/refresh_tokens.sql.go | 99 +++++ internal/database/users.sql.go | 64 ++- main.go | 530 +++++++++++++++++++++--- sql/queries/chirps.sql | 11 + sql/queries/refresh_tokens.sql | 27 ++ sql/queries/users.sql | 16 +- sql/schema/003_hashedpassword.sql | 7 + sql/schema/004_refreshtokens.sql | 13 + 14 files changed, 919 insertions(+), 72 deletions(-) create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/database/refresh_tokens.sql.go create mode 100644 sql/queries/refresh_tokens.sql create mode 100644 sql/schema/003_hashedpassword.sql create mode 100644 sql/schema/004_refreshtokens.sql diff --git a/go.mod b/go.mod index 35748e2..b65607e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,11 @@ module github.com/StevanFreeborn/chirpy go 1.26.3 require ( + github.com/alexedwards/argon2id v1.0.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/joho/godotenv v1.5.1 // indirect github.com/lib/pq v1.12.3 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/sys v0.13.0 // indirect ) diff --git a/go.sum b/go.sum index cb0b5c5..d8958ce 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,50 @@ +github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w= +github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ= github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..8a008ef --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,77 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "net/http" + "strings" + "time" + + "github.com/alexedwards/argon2id" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +func HashPassword(password string) (string, error) { + return argon2id.CreateHash(password, argon2id.DefaultParams) +} + +func CheckPasswordHash(password string, hash string) (bool, error) { + return argon2id.ComparePasswordAndHash(password, hash) +} + +func MakeJWT(userID uuid.UUID, tokenSecret []byte, expiresIn time.Duration) (string, error) { + token := jwt.New(jwt.GetSigningMethod(jwt.SigningMethodHS256.Name)) + + token.Claims = &jwt.RegisteredClaims{ + Issuer: "chirpy-access", + IssuedAt: jwt.NewNumericDate(time.Now()), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(expiresIn)), + Subject: userID.String(), + } + + return token.SignedString(tokenSecret) +} + +func ValidateJWT(tokenString string, tokenSecret []byte) (uuid.UUID, error) { + token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(t *jwt.Token) (any, error) { + return tokenSecret, nil + }) + + if err != nil { + return uuid.Nil, err + } + + userId, err := token.Claims.GetSubject() + + if err != nil { + return uuid.Nil, err + } + + validUserId, err := uuid.Parse(userId) + + if err != nil { + return uuid.Nil, err + } + + return validUserId, nil +} + +func GetBearerToken(headers http.Header) (string, error) { + authorizationHeader := headers.Get("Authorization") + + if strings.TrimSpace(authorizationHeader) == "" { + return "", errors.New("Missing Authorization header") + } + + token := strings.TrimPrefix(authorizationHeader, "Bearer ") + + return token, nil +} + +func MakeRefreshToken() string { + bytes := make([]byte, 32) + rand.Read(bytes) + return hex.EncodeToString(bytes) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..0d5664c --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,21 @@ +package auth_test + +import ( + "testing" + "time" + + "github.com/StevanFreeborn/chirpy/internal/auth" + "github.com/google/uuid" +) + +func TestJwtCreationAndValidation(t *testing.T) { + userId := uuid.New() + secret := []byte("RTCK2UTcOUkiswdrClC6Z3KmmEq/+QicpD9iRx7J0qY=") + + jwtString, err := auth.MakeJWT(userId, secret, time.Hour) + validatedUserId, err := auth.ValidateJWT(jwtString, secret) + + if userId != validatedUserId { + t.Fatalf("received %s expected %s: %v", validatedUserId, userId, err) + } +} diff --git a/internal/database/chirps.sql.go b/internal/database/chirps.sql.go index c51b40a..5777ba2 100644 --- a/internal/database/chirps.sql.go +++ b/internal/database/chirps.sql.go @@ -40,3 +40,64 @@ func (q *Queries) CreateChirp(ctx context.Context, arg CreateChirpParams) (Chirp ) return i, err } + +const deleteChirpById = `-- name: DeleteChirpById :exec +DELETE FROM chirps WHERE id = $1 +` + +func (q *Queries) DeleteChirpById(ctx context.Context, id uuid.UUID) error { + _, err := q.db.ExecContext(ctx, deleteChirpById, id) + return err +} + +const getAllChirps = `-- name: GetAllChirps :many +SELECT id, created_at, updated_at, body, user_id FROM chirps +ORDER BY created_at ASC +` + +func (q *Queries) GetAllChirps(ctx context.Context) ([]Chirp, error) { + rows, err := q.db.QueryContext(ctx, getAllChirps) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Chirp + for rows.Next() { + var i Chirp + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Body, + &i.UserID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getChirpById = `-- name: GetChirpById :one +SELECT id, created_at, updated_at, body, user_id FROM chirps +WHERE id = $1 +` + +func (q *Queries) GetChirpById(ctx context.Context, id uuid.UUID) (Chirp, error) { + row := q.db.QueryRowContext(ctx, getChirpById, id) + var i Chirp + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Body, + &i.UserID, + ) + return i, err +} diff --git a/internal/database/models.go b/internal/database/models.go index f33c6a8..e48f9f8 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -5,6 +5,7 @@ package database import ( + "database/sql" "time" "github.com/google/uuid" @@ -18,9 +19,19 @@ type Chirp struct { UserID uuid.UUID } -type User struct { - ID uuid.UUID +type RefreshToken struct { + Token string CreatedAt time.Time UpdatedAt time.Time - Email string + ExpiresAt time.Time + RevokedAt sql.NullTime + UserID uuid.UUID +} + +type User struct { + ID uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time + Email string + HashedPassword string } diff --git a/internal/database/refresh_tokens.sql.go b/internal/database/refresh_tokens.sql.go new file mode 100644 index 0000000..a1b61f3 --- /dev/null +++ b/internal/database/refresh_tokens.sql.go @@ -0,0 +1,99 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: refresh_tokens.sql + +package database + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" +) + +const createRefreshToken = `-- name: CreateRefreshToken :one +INSERT INTO refresh_tokens (created_at, updated_at, token, expires_at, revoked_at, user_id) +VALUES ( + NOW(), + NOW(), + $1, + $2, + $3, + $4 +) +RETURNING token, created_at, updated_at, expires_at, revoked_at, user_id +` + +type CreateRefreshTokenParams struct { + Token string + ExpiresAt time.Time + RevokedAt sql.NullTime + UserID uuid.UUID +} + +func (q *Queries) CreateRefreshToken(ctx context.Context, arg CreateRefreshTokenParams) (RefreshToken, error) { + row := q.db.QueryRowContext(ctx, createRefreshToken, + arg.Token, + arg.ExpiresAt, + arg.RevokedAt, + arg.UserID, + ) + var i RefreshToken + err := row.Scan( + &i.Token, + &i.CreatedAt, + &i.UpdatedAt, + &i.ExpiresAt, + &i.RevokedAt, + &i.UserID, + ) + return i, err +} + +const getRefreshTokenByToken = `-- name: GetRefreshTokenByToken :one +SELECT + created_at, + updated_at, + token, + expires_at, + revoked_at, + user_id +FROM refresh_tokens +WHERE token = $1 +` + +type GetRefreshTokenByTokenRow struct { + CreatedAt time.Time + UpdatedAt time.Time + Token string + ExpiresAt time.Time + RevokedAt sql.NullTime + UserID uuid.UUID +} + +func (q *Queries) GetRefreshTokenByToken(ctx context.Context, token string) (GetRefreshTokenByTokenRow, error) { + row := q.db.QueryRowContext(ctx, getRefreshTokenByToken, token) + var i GetRefreshTokenByTokenRow + err := row.Scan( + &i.CreatedAt, + &i.UpdatedAt, + &i.Token, + &i.ExpiresAt, + &i.RevokedAt, + &i.UserID, + ) + return i, err +} + +const revokeRefreshToken = `-- name: RevokeRefreshToken :exec +UPDATE refresh_tokens +SET revoked_at = NOW(), updated_at = NOW() +WHERE token = $1 +` + +func (q *Queries) RevokeRefreshToken(ctx context.Context, token string) error { + _, err := q.db.ExecContext(ctx, revokeRefreshToken, token) + return err +} diff --git a/internal/database/users.sql.go b/internal/database/users.sql.go index 29e2e3f..affd674 100644 --- a/internal/database/users.sql.go +++ b/internal/database/users.sql.go @@ -12,24 +12,31 @@ import ( ) const createUser = `-- name: CreateUser :one -INSERT INTO users (id, created_at, updated_at, email) +INSERT INTO users (id, created_at, updated_at, email, hashed_password) VALUES ( gen_random_uuid(), NOW(), NOW(), - $1 + $1, + $2 ) -RETURNING id, created_at, updated_at, email +RETURNING id, created_at, updated_at, email, hashed_password ` -func (q *Queries) CreateUser(ctx context.Context, email string) (User, error) { - row := q.db.QueryRowContext(ctx, createUser, email) +type CreateUserParams struct { + Email string + HashedPassword string +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, createUser, arg.Email, arg.HashedPassword) var i User err := row.Scan( &i.ID, &i.CreatedAt, &i.UpdatedAt, &i.Email, + &i.HashedPassword, ) return i, err } @@ -43,8 +50,26 @@ func (q *Queries) DeleteAllUsers(ctx context.Context) error { return err } +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, created_at, updated_at, email, hashed_password FROM users +WHERE email = $1 +` + +func (q *Queries) GetUserByEmail(ctx context.Context, email string) (User, error) { + row := q.db.QueryRowContext(ctx, getUserByEmail, email) + var i User + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Email, + &i.HashedPassword, + ) + return i, err +} + const getUserById = `-- name: GetUserById :one -SELECT id, created_at, updated_at, email FROM users +SELECT id, created_at, updated_at, email, hashed_password FROM users WHERE id = $1 ` @@ -56,6 +81,33 @@ func (q *Queries) GetUserById(ctx context.Context, id uuid.UUID) (User, error) { &i.CreatedAt, &i.UpdatedAt, &i.Email, + &i.HashedPassword, + ) + return i, err +} + +const updateUser = `-- name: UpdateUser :one +UPDATE users +SET email = $2, hashed_password = $3, updated_at = NOW() +WHERE id = $1 +RETURNING id, created_at, updated_at, email, hashed_password +` + +type UpdateUserParams struct { + ID uuid.UUID + Email string + HashedPassword string +} + +func (q *Queries) UpdateUser(ctx context.Context, arg UpdateUserParams) (User, error) { + row := q.db.QueryRowContext(ctx, updateUser, arg.ID, arg.Email, arg.HashedPassword) + var i User + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.Email, + &i.HashedPassword, ) return i, err } diff --git a/main.go b/main.go index 81bfb23..d073cf7 100644 --- a/main.go +++ b/main.go @@ -16,6 +16,7 @@ import ( "syscall" "time" + "github.com/StevanFreeborn/chirpy/internal/auth" "github.com/StevanFreeborn/chirpy/internal/database" "github.com/google/uuid" "github.com/joho/godotenv" @@ -26,9 +27,13 @@ type server struct { fileServerHits atomic.Int32 database *database.Queries platform string + jwtSecret []byte } -func writeJsonResponse(w http.ResponseWriter, response any) { +func writeJsonResponse(w http.ResponseWriter, statusCode int, response any) { + w.WriteHeader(statusCode) + w.Header().Add("Content-Type", "application/json") + encoder := json.NewEncoder(w) if err := encoder.Encode(response); err != nil { @@ -36,6 +41,21 @@ func writeJsonResponse(w http.ResponseWriter, response any) { } } +func decodeJsonRequest[T any](r *http.Request) (*T, error) { + defer r.Body.Close() + var data *T + decoder := json.NewDecoder(r.Body) + err := decoder.Decode(&data) + + if err != nil { + return nil, apiError{ + Err: "Failed to deserialize request", + } + } + + return data, err +} + func (s *server) HandleFiles(prefix string) http.Handler { fileServer := http.StripPrefix(prefix, http.FileServer(http.Dir("."))) @@ -88,12 +108,16 @@ type apiError struct { Err string `json:"error"` } +func (e apiError) Error() string { + return e.Err +} + type createChirpRequest struct { Body string `json:"body"` UserId string `json:"user_id"` } -type createChirpResponse struct { +type chirpResponse struct { Id string `json:"id"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` @@ -108,50 +132,38 @@ func (s *server) HandleCreateChirp(w http.ResponseWriter, r *http.Request) { "fornax", } - var createChirpRequest createChirpRequest + bearerToken, err := auth.GetBearerToken(r.Header) - defer r.Body.Close() - - w.Header().Add("Content-Type", "application/json") - - err := json.NewDecoder(r.Body).Decode(&createChirpRequest) + unauthorizedError := apiError{ + Err: "You are not authorized to perform this action", + } if err != nil { - w.WriteHeader(http.StatusBadRequest) - writeJsonResponse(w, apiError{ - Err: "Unable to deserialize JSON", - }) + writeJsonResponse(w, http.StatusUnauthorized, unauthorizedError) + return + } + + requestUserId, err := auth.ValidateJWT(bearerToken, s.jwtSecret) + + if err != nil { + writeJsonResponse(w, http.StatusUnauthorized, unauthorizedError) + return + } + + createChirpRequest, err := decodeJsonRequest[createChirpRequest](r) + + if err != nil { + writeJsonResponse(w, http.StatusBadRequest, err) return } if len(createChirpRequest.Body) > 140 { - w.WriteHeader(http.StatusBadRequest) - writeJsonResponse(w, apiError{ + writeJsonResponse(w, http.StatusBadRequest, apiError{ Err: "Chirp is too long", }) return } - requestUserId, err := uuid.Parse(createChirpRequest.UserId) - - invalidUserIdError := apiError{ - Err: "Invalid user_id. user_id must be valid UUID", - } - - if err != nil { - w.WriteHeader(http.StatusBadRequest) - writeJsonResponse(w, invalidUserIdError) - return - } - - existingUser, err := s.database.GetUserById(r.Context(), requestUserId) - - if err != nil { - w.WriteHeader(http.StatusBadRequest) - writeJsonResponse(w, invalidUserIdError) - return - } - words := strings.Split(createChirpRequest.Body, " ") sanitized := []string{} @@ -168,20 +180,19 @@ func (s *server) HandleCreateChirp(w http.ResponseWriter, r *http.Request) { createChirpParams := database.CreateChirpParams{ Body: cleanedBody, - UserID: existingUser.ID, + UserID: requestUserId, } createdChirp, err := s.database.CreateChirp(r.Context(), createChirpParams) if err != nil { - w.WriteHeader(http.StatusInternalServerError) - writeJsonResponse(w, apiError{ + writeJsonResponse(w, http.StatusInternalServerError, apiError{ Err: "Failed to create chirp. 🤷🏻‍♂️", }) + return } - w.WriteHeader(http.StatusCreated) - writeJsonResponse(w, createChirpResponse{ + writeJsonResponse(w, http.StatusCreated, chirpResponse{ Id: createdChirp.ID.String(), CreatedAt: createdChirp.CreatedAt.Format(time.RFC3339), UpdatedAt: createdChirp.UpdatedAt.Format(time.RFC3339), @@ -191,7 +202,8 @@ func (s *server) HandleCreateChirp(w http.ResponseWriter, r *http.Request) { } type createUserRequest struct { - Email string `json:"email"` + Email string `json:"email"` + Password string `json:"password"` } type createUserResponse struct { @@ -202,39 +214,55 @@ type createUserResponse struct { } func (s *server) HandleCreateUser(w http.ResponseWriter, r *http.Request) { - var createUserRequest createUserRequest - - defer r.Body.Close() - - err := json.NewDecoder(r.Body).Decode(&createUserRequest) + createUserRequest, err := decodeJsonRequest[createUserRequest](r) if err != nil { - w.WriteHeader(http.StatusBadRequest) - writeJsonResponse(w, apiError{ - Err: "Unable to deserialize JSON", - }) + writeJsonResponse(w, http.StatusBadRequest, err) + return } trimmedEmail := strings.TrimSpace(createUserRequest.Email) if strings.TrimSpace(trimmedEmail) == "" { - w.WriteHeader(http.StatusBadRequest) - writeJsonResponse(w, apiError{ + writeJsonResponse(w, http.StatusBadRequest, apiError{ Err: "email is required. must be valid email address.", }) + return } - createdUser, err := s.database.CreateUser(r.Context(), trimmedEmail) + trimmedPassword := strings.TrimSpace(createUserRequest.Password) + + if strings.TrimSpace(trimmedEmail) == "" { + writeJsonResponse(w, http.StatusBadRequest, apiError{ + Err: "password is required. must be non-empty string.", + }) + return + } + + hashed_password, err := auth.HashPassword(trimmedPassword) + + createUserError := apiError{ + Err: "Uh oh we were unable to create a new user", + } if err != nil { - w.WriteHeader(http.StatusInternalServerError) - writeJsonResponse(w, apiError{ - Err: "Uh oh we were unable to create a new user", - }) + writeJsonResponse(w, http.StatusInternalServerError, createUserError) + return } - w.WriteHeader(http.StatusCreated) - writeJsonResponse(w, createUserResponse{ + createUserParams := database.CreateUserParams{ + Email: trimmedEmail, + HashedPassword: hashed_password, + } + + createdUser, err := s.database.CreateUser(r.Context(), createUserParams) + + if err != nil { + writeJsonResponse(w, http.StatusInternalServerError, createUserError) + return + } + + writeJsonResponse(w, http.StatusCreated, createUserResponse{ Id: createdUser.ID.String(), CreatedAt: createdUser.CreatedAt.Format(time.RFC3339), UpdatedAt: createdUser.UpdatedAt.Format(time.RFC3339), @@ -242,6 +270,374 @@ func (s *server) HandleCreateUser(w http.ResponseWriter, r *http.Request) { }) } +func (s *server) HandleGetAllChirps(w http.ResponseWriter, r *http.Request) { + existingChirps, err := s.database.GetAllChirps(r.Context()) + + if err != nil { + writeJsonResponse(w, http.StatusInternalServerError, apiError{ + Err: "Unable to retrieve chirps", + }) + return + } + + chirps := []chirpResponse{} + + for _, c := range existingChirps { + chirps = append(chirps, chirpResponse{ + Id: c.ID.String(), + CreatedAt: c.CreatedAt.Format(time.RFC3339), + UpdatedAt: c.UpdatedAt.Format(time.RFC3339), + Body: c.Body, + UserId: c.UserID.String(), + }) + } + + writeJsonResponse(w, http.StatusOK, chirps) +} + +func (s *server) HandleGetChirp(w http.ResponseWriter, r *http.Request) { + chirpId := r.PathValue("id") + validChirpId, err := uuid.Parse(chirpId) + + if err != nil { + writeJsonResponse(w, http.StatusBadRequest, apiError{ + Err: "Chirp id is not valid. id must be valid UUID.", + }) + return + } + + existingChirp, err := s.database.GetChirpById(r.Context(), validChirpId) + + if err != nil { + writeJsonResponse(w, http.StatusNotFound, apiError{ + Err: "No chirp with given id found.", + }) + return + } + + writeJsonResponse(w, http.StatusOK, chirpResponse{ + Id: existingChirp.ID.String(), + CreatedAt: existingChirp.CreatedAt.Format(time.RFC3339), + UpdatedAt: existingChirp.UpdatedAt.Format(time.RFC3339), + Body: existingChirp.Body, + UserId: existingChirp.UserID.String(), + }) +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +func (r *loginRequest) Validate() error { + if strings.TrimSpace(r.Email) == "" || strings.TrimSpace(r.Password) == "" { + return apiError{ + Err: "Email and password must be non-empty string", + } + } + + return nil +} + +type loginResponse struct { + Id string `json:"id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Email string `json:"email"` + Token string `json:"token"` + RefreshToken string `json:"refresh_token"` +} + +func (s *server) HandleLogin(w http.ResponseWriter, r *http.Request) { + loginRequest, err := decodeJsonRequest[loginRequest](r) + + err = loginRequest.Validate() + + if err != nil { + writeJsonResponse(w, http.StatusBadRequest, err) + return + } + + existingUser, err := s.database.GetUserByEmail(r.Context(), loginRequest.Email) + + invalidLoginError := apiError{ + Err: "Invalid login request", + } + + if err != nil { + writeJsonResponse(w, http.StatusUnauthorized, invalidLoginError) + return + } + + isCorrectPassword, err := auth.CheckPasswordHash(loginRequest.Password, existingUser.HashedPassword) + + if err != nil || isCorrectPassword == false { + writeJsonResponse(w, http.StatusUnauthorized, invalidLoginError) + return + } + + SECONDS_PER_HOUR := 3600 + expiresInDuration := time.Duration(SECONDS_PER_HOUR) * time.Second + + accessToken, err := auth.MakeJWT(existingUser.ID, s.jwtSecret, expiresInDuration) + + loginFailedError := apiError{ + Err: "Login failed", + } + + if err != nil { + writeJsonResponse(w, http.StatusInternalServerError, loginFailedError) + return + } + + createRefreshTokenParams := database.CreateRefreshTokenParams{ + Token: auth.MakeRefreshToken(), + ExpiresAt: time.Now().Add(60 * 24 * time.Hour), + RevokedAt: sql.NullTime{ + Valid: false, + }, + UserID: existingUser.ID, + } + + createdRefreshToken, err := s.database.CreateRefreshToken(r.Context(), createRefreshTokenParams) + + if err != nil { + writeJsonResponse(w, http.StatusInternalServerError, loginFailedError) + return + } + + writeJsonResponse(w, http.StatusOK, loginResponse{ + Id: existingUser.ID.String(), + CreatedAt: existingUser.CreatedAt.Format(time.RFC3339), + UpdatedAt: existingUser.UpdatedAt.Format(time.RFC3339), + Email: existingUser.Email, + Token: accessToken, + RefreshToken: createdRefreshToken.Token, + }) +} + +type refreshTokenResponse struct { + Token string `json:"token"` +} + +func (s *server) HandleRefresh(w http.ResponseWriter, r *http.Request) { + refreshToken, err := auth.GetBearerToken(r.Header) + + unauthorizedError := apiError{ + Err: "Unable to refresh token", + } + + if err != nil { + writeJsonResponse(w, http.StatusUnauthorized, unauthorizedError) + return + } + + existingRefreshToken, err := s.database.GetRefreshTokenByToken(r.Context(), refreshToken) + + if err != nil { + writeJsonResponse(w, http.StatusUnauthorized, unauthorizedError) + return + } + + if existingRefreshToken.ExpiresAt.Before(time.Now()) || existingRefreshToken.RevokedAt.Valid { + writeJsonResponse(w, http.StatusUnauthorized, unauthorizedError) + return + } + + SECONDS_PER_HOUR := 3600 + expiresInDuration := time.Duration(SECONDS_PER_HOUR) * time.Second + + accessToken, err := auth.MakeJWT(existingRefreshToken.UserID, s.jwtSecret, expiresInDuration) + + if err != nil { + writeJsonResponse(w, http.StatusInternalServerError, apiError{ + Err: "Failed to refresh token", + }) + return + } + + // TODO: We should rotate the refresh token + + writeJsonResponse(w, http.StatusOK, refreshTokenResponse{ + Token: accessToken, + }) +} + +func (s *server) HandleRevoke(w http.ResponseWriter, r *http.Request) { + refreshToken, err := auth.GetBearerToken(r.Header) + + if err != nil { + writeJsonResponse(w, http.StatusBadRequest, apiError{ + Err: "No refresh token present in request", + }) + return + } + + existingRefreshToken, err := s.database.GetRefreshTokenByToken(r.Context(), refreshToken) + + if err != nil { + writeJsonResponse(w, http.StatusNotFound, apiError{ + Err: "Unable to revoke token", + }) + return + } + + err = s.database.RevokeRefreshToken(r.Context(), existingRefreshToken.Token) + + if err != nil { + writeJsonResponse(w, http.StatusInternalServerError, apiError{ + Err: "Unable to revoke token", + }) + return + } + + writeJsonResponse(w, http.StatusNoContent, nil) +} + +type updateUserRequest struct { + Email string `json:"email"` + Password string `json:"password"` +} + +type updateUserResponse struct { + Id string `json:"id"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + Email string `json:"email"` +} + +func (r *updateUserRequest) Validate() error { + if strings.TrimSpace(r.Email) == "" || strings.TrimSpace(r.Password) == "" { + return apiError{ + Err: "Email and password must be non-empty string", + } + } + + return nil +} + +func (s *server) HandleUpdateUser(w http.ResponseWriter, r *http.Request) { + accessToken, err := auth.GetBearerToken(r.Header) + + if err != nil { + writeJsonResponse(w, http.StatusUnauthorized, apiError{ + Err: "You are not authorized to perform this action", + }) + return + } + + requestUserId, err := auth.ValidateJWT(accessToken, s.jwtSecret) + + if err != nil { + writeJsonResponse(w, http.StatusUnauthorized, apiError{ + Err: "You are not authorized to perform this action", + }) + return + } + + updateUserRequest, err := decodeJsonRequest[updateUserRequest](r) + + if err != nil { + writeJsonResponse(w, http.StatusBadRequest, err) + return + } + + err = updateUserRequest.Validate() + + if err != nil { + writeJsonResponse(w, http.StatusBadRequest, err) + return + } + + updatedHashedPassword, err := auth.HashPassword(updateUserRequest.Password) + + updateError := apiError{ + Err: "Unable to perform update", + } + + if err != nil { + writeJsonResponse(w, http.StatusInternalServerError, updateError) + return + } + + updateUserParams := database.UpdateUserParams{ + ID: requestUserId, + Email: updateUserRequest.Email, + HashedPassword: updatedHashedPassword, + } + + updatedUser, err := s.database.UpdateUser(r.Context(), updateUserParams) + + if err != nil { + writeJsonResponse(w, http.StatusInternalServerError, updateError) + return + } + + writeJsonResponse(w, http.StatusOK, updateUserResponse{ + Id: updatedUser.ID.String(), + CreatedAt: updatedUser.CreatedAt.Format(time.RFC3339), + UpdatedAt: updatedUser.UpdatedAt.Format(time.RFC3339), + Email: updatedUser.Email, + }) +} + +func (s *server) HandleDeleteChirp(w http.ResponseWriter, r *http.Request) { + accessToken, err := auth.GetBearerToken(r.Header) + + unauthorizedError := apiError{ + Err: "You are not authorized to perform this action", + } + + if err != nil { + writeJsonResponse(w, http.StatusUnauthorized, unauthorizedError) + return + } + + requestUserId, err := auth.ValidateJWT(accessToken, s.jwtSecret) + + if err != nil { + writeJsonResponse(w, http.StatusUnauthorized, unauthorizedError) + return + } + + chirpId := r.PathValue("id") + validChirpId, err := uuid.Parse(chirpId) + + invalidChirpIdError := apiError{ + Err: "Chirp id must be a valid uuid for an existing chirp", + } + + if err != nil { + writeJsonResponse(w, http.StatusBadRequest, invalidChirpIdError) + return + } + + existingChirp, err := s.database.GetChirpById(r.Context(), validChirpId) + + if err != nil { + writeJsonResponse(w, http.StatusNotFound, invalidChirpIdError) + return + } + + if existingChirp.UserID != requestUserId { + writeJsonResponse(w, http.StatusForbidden, apiError{ + Err: "You can not delete a chirp that does not belong to you", + }) + return + } + + err = s.database.DeleteChirpById(r.Context(), existingChirp.ID) + + if err != nil { + writeJsonResponse(w, http.StatusInternalServerError, apiError{ + Err: "Yo bro we couldn't delete that shit", + }) + return + } + + writeJsonResponse(w, http.StatusNoContent, nil) +} + func main() { err := godotenv.Load() @@ -252,6 +648,12 @@ func main() { dbURL := os.Getenv("DB_URL") platform := os.Getenv("PLATFORM") + jwtSecret := os.Getenv("JWT_SECRET") + + if strings.TrimSpace(jwtSecret) == "" { + log.Fatalf("Failed to retrieve JWT_SECRET") + os.Exit(1) + } db, err := sql.Open("postgres", dbURL) @@ -261,8 +663,9 @@ func main() { } server := &server{ - database: database.New(db), - platform: platform, + database: database.New(db), + platform: platform, + jwtSecret: []byte(jwtSecret), } mux := http.NewServeMux() @@ -275,9 +678,16 @@ func main() { mux.HandleFunc("GET /api/healthz", server.HandleHealthChecks) + mux.HandleFunc("POST /api/login", server.HandleLogin) + mux.HandleFunc("POST /api/refresh", server.HandleRefresh) + mux.HandleFunc("POST /api/revoke", server.HandleRevoke) mux.HandleFunc("POST /api/users", server.HandleCreateUser) + mux.HandleFunc("PUT /api/users", server.HandleUpdateUser) + mux.HandleFunc("GET /api/chirps", server.HandleGetAllChirps) + mux.HandleFunc("GET /api/chirps/{id}", server.HandleGetChirp) mux.HandleFunc("POST /api/chirps", server.HandleCreateChirp) + mux.HandleFunc("DELETE /api/chirps/{id}", server.HandleDeleteChirp) port := ":8080" diff --git a/sql/queries/chirps.sql b/sql/queries/chirps.sql index 664a1a5..4a0e0c3 100644 --- a/sql/queries/chirps.sql +++ b/sql/queries/chirps.sql @@ -8,3 +8,14 @@ VALUES ( $2 ) RETURNING *; + +-- name: GetAllChirps :many +SELECT id, created_at, updated_at, body, user_id FROM chirps +ORDER BY created_at ASC; + +-- name: GetChirpById :one +SELECT id, created_at, updated_at, body, user_id FROM chirps +WHERE id = $1; + +-- name: DeleteChirpById :exec +DELETE FROM chirps WHERE id = $1; diff --git a/sql/queries/refresh_tokens.sql b/sql/queries/refresh_tokens.sql new file mode 100644 index 0000000..577181d --- /dev/null +++ b/sql/queries/refresh_tokens.sql @@ -0,0 +1,27 @@ +-- name: CreateRefreshToken :one +INSERT INTO refresh_tokens (created_at, updated_at, token, expires_at, revoked_at, user_id) +VALUES ( + NOW(), + NOW(), + $1, + $2, + $3, + $4 +) +RETURNING *; + +-- name: GetRefreshTokenByToken :one +SELECT + created_at, + updated_at, + token, + expires_at, + revoked_at, + user_id +FROM refresh_tokens +WHERE token = $1; + +-- name: RevokeRefreshToken :exec +UPDATE refresh_tokens +SET revoked_at = NOW(), updated_at = NOW() +WHERE token = $1; diff --git a/sql/queries/users.sql b/sql/queries/users.sql index c65a5c6..b53b17d 100644 --- a/sql/queries/users.sql +++ b/sql/queries/users.sql @@ -1,17 +1,27 @@ -- name: CreateUser :one -INSERT INTO users (id, created_at, updated_at, email) +INSERT INTO users (id, created_at, updated_at, email, hashed_password) VALUES ( gen_random_uuid(), NOW(), NOW(), - $1 + $1, + $2 ) RETURNING *; +-- name: UpdateUser :one +UPDATE users +SET email = $2, hashed_password = $3, updated_at = NOW() +WHERE id = $1 +RETURNING *; -- name: DeleteAllUsers :exec DELETE FROM users; -- name: GetUserById :one -SELECT id, created_at, updated_at, email FROM users +SELECT id, created_at, updated_at, email, hashed_password FROM users WHERE id = $1; + +-- name: GetUserByEmail :one +SELECT id, created_at, updated_at, email, hashed_password FROM users +WHERE email = $1; diff --git a/sql/schema/003_hashedpassword.sql b/sql/schema/003_hashedpassword.sql new file mode 100644 index 0000000..d4cefd7 --- /dev/null +++ b/sql/schema/003_hashedpassword.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE users +ADD hashed_password TEXT NOT NULL DEFAULT 'unset'; + +-- +goose Down +ALTER TABLE users +DROP COLUMN hashed_password; diff --git a/sql/schema/004_refreshtokens.sql b/sql/schema/004_refreshtokens.sql new file mode 100644 index 0000000..d1e4621 --- /dev/null +++ b/sql/schema/004_refreshtokens.sql @@ -0,0 +1,13 @@ +-- +goose Up +CREATE TABLE refresh_tokens ( + token TEXT PRIMARY KEY, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP NULL, + user_id UUID NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +-- +goose Down +DROP TABLE refresh_tokens;