diff --git a/.github/workflows/deploy-golang-develop.yml b/.github/workflows/deploy-golang-develop.yml index 91e977ad..d9d628b7 100644 --- a/.github/workflows/deploy-golang-develop.yml +++ b/.github/workflows/deploy-golang-develop.yml @@ -69,7 +69,7 @@ jobs: - name: Run tests run: | - go test -v -coverpkg=github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils,github.com/COS301-SE-2024/occupi/occupi-backend/pkg/handlers ./tests/... -coverprofile=coverage.out + go test -v -coverpkg=github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils,github.com/COS301-SE-2024/occupi/occupi-backend/pkg/authenticator,github.com/COS301-SE-2024/occupi/occupi-backend/pkg/middleware ./tests/... -coverprofile=coverage.out - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4.0.1 diff --git a/.github/workflows/deploy-golang-prod.yml b/.github/workflows/deploy-golang-prod.yml index d130de75..9cfa7e78 100644 --- a/.github/workflows/deploy-golang-prod.yml +++ b/.github/workflows/deploy-golang-prod.yml @@ -61,7 +61,7 @@ jobs: - name: Run tests run: | - go test -v -coverpkg=github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils,github.com/COS301-SE-2024/occupi/occupi-backend/pkg/handlers ./tests/... -coverprofile=coverage.out + go test -v -coverpkg=github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils,github.com/COS301-SE-2024/occupi/occupi-backend/pkg/authenticator,github.com/COS301-SE-2024/occupi/occupi-backend/pkg/middleware ./tests/... -coverprofile=coverage.out - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4.0.1 diff --git a/.github/workflows/lint-test-build-golang.yml b/.github/workflows/lint-test-build-golang.yml index c03916f5..ec411998 100644 --- a/.github/workflows/lint-test-build-golang.yml +++ b/.github/workflows/lint-test-build-golang.yml @@ -67,7 +67,7 @@ jobs: - name: Run tests run: | - go test -v -coverpkg=github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils,github.com/COS301-SE-2024/occupi/occupi-backend/pkg/handlers ./tests/... -coverprofile=coverage.out + go test -v -coverpkg=github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils,github.com/COS301-SE-2024/occupi/occupi-backend/pkg/authenticator,github.com/COS301-SE-2024/occupi/occupi-backend/pkg/middleware ./tests/... -coverprofile=coverage.out - name: Upload coverage reports to Codecov uses: codecov/codecov-action@v4.0.1 diff --git a/documentation/occupi-docs/pages/api-documentation/api-usage.mdx b/documentation/occupi-docs/pages/api-documentation/api-usage.mdx index f1ed70ce..b79e54c1 100644 --- a/documentation/occupi-docs/pages/api-documentation/api-usage.mdx +++ b/documentation/occupi-docs/pages/api-documentation/api-usage.mdx @@ -11,10 +11,15 @@ The API also allows you to retrieve information about these resources. - [Table of Contents](#table-of-contents) - [Base URL](#base-url) - [Ping](#ping) + - [Ping](#ping-1) + - [Ping-Auth](#ping-auth) + - [Ping-Admin](#ping-admin) - [Authentication](#authentication) - [Register](#register) - [Login](#login) + - [Login-Admin](#login-admin) - [Verify OTP](#verify-otp) + - [Logout](#logout) - [Api](#api) - [Resources](#resources) - [BookRoom](#BookRoom) @@ -26,10 +31,12 @@ The API also allows you to retrieve information about these resources. ## Base URL -The base URL for the Occupi API is `https://occupi.tech` or `https://localhost:8080` if you are in develop mode. +The base URL for the Occupi API is `https://occupi.tech`, `https://dev.occupi.tech` or `https://localhost:8080` if you are in develop mode. ## Ping +### Ping + The ping endpoint is used to check if the API is up and running. - **URL** @@ -43,7 +50,7 @@ The ping endpoint is used to check if the API is up and running. - **Success Response** - **Code:** 200 - - **Content:** `{ "message": "pong -> I am alive and kicking" }` + - **Content:** `{ "status": 200, "message": "pong -> I am alive and kicking", "data": {}, }` - **Error Response** - **Code:** 404 @@ -55,9 +62,65 @@ The ping endpoint is used to check if the API is up and running. {} ``` +### Ping-Auth + +The ping-auth endpoint is used to check if the API is up and running and also to check if the user is authenticated. + +- **URL** + + `/ping-auth` + +- **Method** + + `GET` + +- **Success Response** + + - **Code:** 200 + - **Content:** `{ "status": 200, "message": "pong -> I am alive and kicking and you are auth'd", "data": {}, }` + +- **Error Response** + + - **Code:** 401 + - **Content:** `{\"error\":{\"code\":\"INVALID_AUTH\",\"details\":null,\"message\":\"User not authorized\"},\"message\":\"Bad Request\",\"status\":401}` + +**_Example json to send:_** + + ```json copy + {} + ``` + +### Ping-Admin + +The ping-admin endpoint is used to check if the API is up and running and also to check if the user is an admin. + +- **URL** + + `/ping-admin` + +- **Method** + + `GET` + +- **Success Response** + + - **Code:** 200 + - **Content:** `{ "status": 200, "message": "pong -> I am alive and kicking and you are an admin", "data": {}, }` + +- **Error Response** + + - **Code:** 401 + - **Content:** `{\"error\":{\"code\":\"INVALID_AUTH\",\"details\":null,\"message\":\"User not authorized to access admin route\"},\"message\":\"Bad Request\",\"status\":401}` + +**_Example json to send:_** + + ```json copy + {} + ``` + ## Authentication -The authentication endpoints are used to register, login, and verify users. Only POST requests are used for these endpoints. +The authentication endpoints are used to register, login, login-admin, logout, and verify users. Only POST requests are used for these endpoints. ### Register @@ -125,6 +188,39 @@ The authentication endpoints are used to register, login, and verify users. Only } ``` +### Login-Admin + +- **URL** + + `/auth/login-admin` + +- **Method** + + `POST` + +- **Success Response** + + - **Code:** 200 + - **Content:** `{ "status": 200, "message": "Successful login!", "data": {}, }` + +- **Error Response** + + - **Code:** 400 + - **Content:** `{"status": 400, "message": "Invalid email address": {"code": "INVALID_REQUEST_PAYLOAD","message": "Expected a valid format for email address": {}}}` + +- **Error Response** + - **Code:** 500 + - **Content:** `{"status": 500, "message": "Internal Server Error","error": {"code": "INTERNAL_SERVER_ERROR","message": "Internal Server Error","details": {}}}` + +**_Example json to send:_** + +```json copy +{ + "email": "abcd@gmail.com", + "password": "123456" +} +``` + ### Verify OTP - **URL** @@ -158,6 +254,35 @@ The authentication endpoints are used to register, login, and verify users. Only } ``` +### Logout + +- **URL** + + `/auth/logout` + +- **Method** + + `POST` + +- **Success Response** + + - **Code:** 200 + - **Content:** `{ "status": 200, "message": "Logout successful!", "data": {}, }` + +- **Error Response** + + - **Code:** 400 + - **Content:** `{\"error\":{\"code\":\"INVALID_AUTH\",\"details\":null,\"message\":\"Authorized user can't access this route\"},\"message\":\"Bad Request\",\"status\":401}` + +- **Error Response** + - **Code:** 500 + - **Content:** `{"status": 500, "message": "Internal Server Error","error": {"code": "INTERNAL_SERVER_ERROR","message": "Internal Server Error","details": {}}}` + +**_Example json to send:_** +```json copy +{} +``` + ## Api The API endpoints are used to interact with the Occupi platform. Mainly GET, POST, PUT, DELETE requests are used. diff --git a/documentation/occupi-docs/theme.config.jsx b/documentation/occupi-docs/theme.config.jsx index cec5a5bf..38e2bd6f 100644 --- a/documentation/occupi-docs/theme.config.jsx +++ b/documentation/occupi-docs/theme.config.jsx @@ -1,7 +1,13 @@ export default { - title: 'Occupi', - favicon: 'https://raw.githubusercontent.com/COS301-SE-2024/occupi/5f614e7d881c9d4f65ec2cf6ea60bf5542eb77a7/presentation/Occupi/image_2024-05-21_213821107.svg', - description: 'This is occupi-s documentation site', + head: ( + <> + + + + + + + ), logo: ( <> I am alive and kicking", nil)) +} + +// PingHanlderAuth is a simple handler for testing if the server is up and running but requires authentication +func PingHandlerAuth(ctx *gin.Context) { + ctx.JSON(http.StatusOK, utils.SuccessResponse(http.StatusOK, "pong -> I am alive and kicking and you are auth'd", nil)) +} + +// PingHandlerAdmin is a simple handler for testing if the server is up and running but requires admin authentication +func PingHandlerAdmin(ctx *gin.Context) { + ctx.JSON(http.StatusOK, utils.SuccessResponse(http.StatusOK, "pong -> I am alive and kicking and you are an admin", nil)) +} + // handler for fetching test resource from /api/resource. Formats and returns json response func FetchResource(ctx *gin.Context, appsession *models.AppSession) { data := database.GetAllData(appsession.DB) - ctx.JSON(http.StatusOK, gin.H{ - "status": http.StatusOK, - "message": "Data fetched successfully", - "data": data, - }) + ctx.JSON(http.StatusOK, utils.SuccessResponse(http.StatusOK, "Data fetched successfully", data)) } // handler for fetching test resource from /api/resource. Formats and returns json response func FetchResourceAuth(ctx *gin.Context, appsession *models.AppSession) { data := database.GetAllData(appsession.DB) - ctx.JSON(http.StatusOK, gin.H{ - "status": http.StatusOK, - "message": "Data fetched successfully and authenticated", - "data": data, - }) + ctx.JSON(http.StatusOK, utils.SuccessResponse(http.StatusOK, "Data fetched successfully", data)) } // BookRoom handles booking a room and sends a confirmation email diff --git a/occupi-backend/pkg/handlers/auth_handlers.go b/occupi-backend/pkg/handlers/auth_handlers.go index 159a299f..004cf145 100644 --- a/occupi-backend/pkg/handlers/auth_handlers.go +++ b/occupi-backend/pkg/handlers/auth_handlers.go @@ -2,21 +2,22 @@ package handlers import ( "net/http" - "net/url" + "time" - "github.com/COS301-SE-2024/occupi/occupi-backend/configs" + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/authenticator" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/constants" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/database" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/mail" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/models" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils" + "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" ) // handler for logging a new user on occupi /auth/login -func Login(ctx *gin.Context, appsession *models.AppSession) { +func Login(ctx *gin.Context, appsession *models.AppSession, role string) { var requestUser models.RequestUser if err := ctx.ShouldBindBodyWithJSON(&requestUser); err != nil { ctx.JSON(http.StatusBadRequest, utils.ErrorResponse( @@ -109,6 +110,26 @@ func Login(ctx *gin.Context, appsession *models.AppSession) { return } + // check if the user is an admin + if role == constants.Admin { + isAdmin, err := database.CheckIfUserIsAdmin(ctx, appsession.DB, requestUser.Email) + if err != nil { + ctx.JSON(http.StatusInternalServerError, utils.InternalServerError()) + logrus.Error(err) + return + } + + if !isAdmin { + ctx.JSON(http.StatusBadRequest, utils.ErrorResponse( + http.StatusBadRequest, + "Not an admin", + constants.InvalidAuthCode, + "Only admins can access this route", + nil)) + return + } + } + // check if the next verification date is due due, err := database.CheckIfNextVerificationDateIsDue(ctx, appsession.DB, requestUser.Email) if err != nil { @@ -118,25 +139,39 @@ func Login(ctx *gin.Context, appsession *models.AppSession) { } if due { - ReverifyUsersEmail(ctx, appsession, requestUser.Email) + reverifyUsersEmail(ctx, appsession, requestUser.Email) return } - state, err := utils.GenerateRandomState() + // generate a jwt token for the user + var token string + var expirationTime time.Time + if role == constants.Admin { + token, expirationTime, err = authenticator.GenerateToken(requestUser.Email, constants.Admin) + } else { + token, expirationTime, err = authenticator.GenerateToken(requestUser.Email, "user") + } + if err != nil { ctx.JSON(http.StatusInternalServerError, utils.InternalServerError()) logrus.Error(err) return } - // Save the state inside the session. + // set the jwt token in the cookie session := sessions.Default(ctx) - session.Set("state", state) + session.Set("email", requestUser.Email) + if role == constants.Admin { + session.Set("role", constants.Admin) + } else { + session.Set("role", constants.Basic) + } if err := session.Save(); err != nil { ctx.JSON(http.StatusInternalServerError, utils.InternalServerError()) logrus.Error(err) return } + ctx.SetCookie("token", token, int(expirationTime.Unix()), "/", "", false, true) ctx.JSON(http.StatusOK, utils.SuccessResponse( http.StatusOK, @@ -144,9 +179,6 @@ func Login(ctx *gin.Context, appsession *models.AppSession) { nil)) } -// redirect to the Auth0 login page -> social auth stuff here -// ctx.Redirect(http.StatusTemporaryRedirect, appsession.Authenticator.AuthCodeURL(state)) - // handler for registering a new user on occupi /auth/register func Register(ctx *gin.Context, appsession *models.AppSession) { var requestUser models.RequestUser @@ -228,23 +260,6 @@ func Register(ctx *gin.Context, appsession *models.AppSession) { return } - // generete auth0 session and token - state, err := utils.GenerateRandomState() - if err != nil { - ctx.JSON(http.StatusInternalServerError, utils.InternalServerError()) - logrus.Error(err) - return - } - - // Save the state inside the session. - session := sessions.Default(ctx) - session.Set("state", state) - if err := session.Save(); err != nil { - ctx.JSON(http.StatusInternalServerError, utils.InternalServerError()) - logrus.Error(err) - return - } - subject := "Email Verification - Your One-Time Password (OTP)" body := mail.FormatEmailVerificationBody(otp, requestUser.Email) @@ -338,7 +353,7 @@ func VerifyOTP(ctx *gin.Context, appsession *models.AppSession) { } // handler for reverifying a users email address -func ReverifyUsersEmail(ctx *gin.Context, appsession *models.AppSession, email string) { +func reverifyUsersEmail(ctx *gin.Context, appsession *models.AppSession, email string) { // generate a random otp for the user and send email otp, err := utils.GenerateOTP() if err != nil { @@ -374,31 +389,19 @@ func ResetPassword(ctx *gin.Context, appsession *models.AppSession) { // this will contain reset password logic } -// handler for logging out a user on occupi /auth/logout -func Logout(c *gin.Context) { - logoutURL, err := url.Parse("https://" + configs.GetAuth0Domain() + "/v2/logout") - if err != nil { - c.JSON(http.StatusInternalServerError, utils.InternalServerError()) - logrus.Error(err) - return - } - - scheme := "http" - if c.Request.TLS != nil { - scheme = "https" - } - - returnTo, err := url.Parse(scheme + "://" + c.Request.Host) - if err != nil { - c.JSON(http.StatusInternalServerError, utils.InternalServerError()) +// handler for logging out a user on occupi /auth/logout TODO: complete implementation +func Logout(ctx *gin.Context) { + session := sessions.Default(ctx) + session.Clear() + if err := session.Save(); err != nil { + ctx.JSON(http.StatusInternalServerError, utils.InternalServerError()) logrus.Error(err) return } - parameters := url.Values{} - parameters.Add("returnTo", returnTo.String()) - parameters.Add("client_id", configs.GetAuth0ClientID()) - logoutURL.RawQuery = parameters.Encode() - - c.Redirect(http.StatusTemporaryRedirect, logoutURL.String()) + ctx.SetCookie("token", "", -1, "/", "localhost", false, true) + ctx.JSON(http.StatusOK, utils.SuccessResponse( + http.StatusOK, + "Logged out successfully!", + nil)) } diff --git a/occupi-backend/pkg/handlers/callback_handlers.go b/occupi-backend/pkg/handlers/callback_handlers.go deleted file mode 100644 index 1a95cec8..00000000 --- a/occupi-backend/pkg/handlers/callback_handlers.go +++ /dev/null @@ -1,53 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/models" - "github.com/gin-contrib/sessions" - "github.com/gin-gonic/gin" - "github.com/sirupsen/logrus" -) - -// Handler for our callback. -func CallbackHandler(c *gin.Context, appsession *models.AppSession) { - session := sessions.Default(c) - if c.Query("state") != session.Get("state") { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) - logrus.Error("Invalid state parameter.") - return - } - - // Exchange an authorization code for a token. - token, err := appsession.Authenticator.Exchange(c.Request.Context(), c.Query("code")) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) - logrus.Error(err) - return - } - - idToken, err := appsession.Authenticator.VerifyIDToken(c.Request.Context(), token) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify ID Token."}) - logrus.Error(err) - return - } - - var profile map[string]interface{} - if err := idToken.Claims(&profile); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unmarshal ID Token."}) - logrus.Error(err) - return - } - - session.Set("access_token", token.AccessToken) - session.Set("profile", profile) - if err := session.Save(); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Internal server error"}) - logrus.Error(err) - return - } - - // Redirect to logged in page. - c.Redirect(http.StatusTemporaryRedirect, "/api/resource-auth") -} diff --git a/occupi-backend/pkg/middleware/middleware.go b/occupi-backend/pkg/middleware/middleware.go index 3b3b2536..b1c0f905 100644 --- a/occupi-backend/pkg/middleware/middleware.go +++ b/occupi-backend/pkg/middleware/middleware.go @@ -3,47 +3,101 @@ package middleware import ( "net/http" - "github.com/gin-contrib/sessions" + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/authenticator" + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/constants" + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils" "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" "github.com/ulule/limiter/v3" mgin "github.com/ulule/limiter/v3/drivers/middleware/gin" "github.com/ulule/limiter/v3/drivers/store/memory" + + "github.com/gin-contrib/sessions" ) // ProtectedRoute is a middleware that checks if // the user has already been authenticated previously. func ProtectedRoute(ctx *gin.Context) { - if sessions.Default(ctx).Get("profile") == nil { - // If the user is not authenticated, return a 401 Unauthorized response - ctx.JSON(http.StatusUnauthorized, gin.H{ - "status": http.StatusUnauthorized, - "message": "Bad Request", - "error": "User not authenticated", - }) - // Add the following so that the next() doesn't get called + tokenStr, err := ctx.Cookie("token") + if err != nil { + ctx.JSON(http.StatusUnauthorized, + utils.ErrorResponse( + http.StatusUnauthorized, + "Bad Request", + constants.InvalidAuthCode, + "User not authorized", + nil)) + ctx.Abort() + return + } + + claims, err := authenticator.ValidateToken(tokenStr) + + if err != nil { + ctx.JSON(http.StatusUnauthorized, + utils.ErrorResponse( + http.StatusUnauthorized, + "Bad Request", + constants.InvalidAuthCode, + "User not authorized", + nil)) + ctx.Abort() + return + } + + session := sessions.Default(ctx) + session.Set("email", claims.Email) + session.Set("role", claims.Role) + if err := session.Save(); err != nil { + ctx.JSON(http.StatusInternalServerError, utils.InternalServerError()) + logrus.Error(err) ctx.Abort() return - } else { - ctx.Next() } + ctx.Next() } // ProtectedRoute is a middleware that checks if // the user has not been authenticated previously. func UnProtectedRoute(ctx *gin.Context) { - if sessions.Default(ctx).Get("profile") != nil { - // If the user is authenticated, return a 401 Unauthorized response - ctx.JSON(http.StatusUnauthorized, gin.H{ - "status": http.StatusUnauthorized, - "message": "Bad Request", - "error": "User already authenticated", - }) - // Add the following so that the next() doesn't get called + tokenStr, err := ctx.Cookie("token") + if err == nil { + _, err := authenticator.ValidateToken(tokenStr) + + if err == nil { + ctx.JSON(http.StatusUnauthorized, + utils.ErrorResponse( + http.StatusUnauthorized, + "Bad Request", + constants.InvalidAuthCode, + "User already authorized", + nil)) + ctx.Abort() + return + } + } + + ctx.Next() +} + +// AdminRoute is a middleware that checks if +// the user has the admin role. +func AdminRoute(ctx *gin.Context) { + session := sessions.Default(ctx) + role := session.Get("role") + if role != constants.Admin { + ctx.JSON(http.StatusUnauthorized, + utils.ErrorResponse( + http.StatusUnauthorized, + "Bad Request", + constants.InvalidAuthCode, + "User not authorized to access admin route", + nil)) ctx.Abort() return - } else { - ctx.Next() } + + ctx.Next() } // AttachRateLimitMiddleware attaches the rate limit middleware to the router. diff --git a/occupi-backend/pkg/models/app.go b/occupi-backend/pkg/models/app.go index dd50a757..a44047b1 100644 --- a/occupi-backend/pkg/models/app.go +++ b/occupi-backend/pkg/models/app.go @@ -1,20 +1,17 @@ package models import ( - "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/authenticator" "go.mongodb.org/mongo-driver/mongo" ) // state management for the web app during runtime type AppSession struct { - Authenticator *authenticator.Authenticator - DB *mongo.Client + DB *mongo.Client } // constructor for app session -func New(authenticator *authenticator.Authenticator, db *mongo.Client) *AppSession { +func New(db *mongo.Client) *AppSession { return &AppSession{ - Authenticator: authenticator, - DB: db, + DB: db, } } diff --git a/occupi-backend/pkg/router/router.go b/occupi-backend/pkg/router/router.go index 6882f3d8..f105ff9a 100644 --- a/occupi-backend/pkg/router/router.go +++ b/occupi-backend/pkg/router/router.go @@ -1,13 +1,12 @@ package router import ( - "encoding/gob" - "net/http" - - "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/authenticator" + "github.com/COS301-SE-2024/occupi/occupi-backend/configs" + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/constants" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/handlers" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/middleware" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/models" + "github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions/cookie" "github.com/gin-gonic/gin" @@ -16,44 +15,43 @@ import ( // creates available endpoints and attaches handlers for each endpoint func OccupiRouter(router *gin.Engine, db *mongo.Client) { - authenticator, err := authenticator.New() - if err != nil { - panic(err) - } - // creating a new valid session for management of shared variables - appsession := models.New(authenticator, db) - - // To store custom types in our cookies, - // we must first register them using gob.Register - gob.Register(map[string]interface{}{}) + appsession := models.New(db) - store := cookie.NewStore([]byte("secret")) - router.Use(sessions.Sessions("auth-session", store)) - - router.Static("/landing", "./web/landing") - router.Static("/app/dashboard", "./web/dashboard") - router.Static("/documentation", "./web/documentation") + store := cookie.NewStore([]byte(configs.GetSessionSecret())) + router.Use(sessions.Sessions("occupi-sessions-store", store)) ping := router.Group("/ping") { - ping.GET("", func(ctx *gin.Context) { ctx.JSON(http.StatusOK, gin.H{"message": "pong -> I am alive and kicking"}) }) + ping.GET("", func(ctx *gin.Context) { handlers.PingHandler(ctx) }) + } + pingAuth := router.Group("/ping-auth") + { + pingAuth.GET("", middleware.ProtectedRoute, func(ctx *gin.Context) { handlers.PingHandlerAuth(ctx) }) + } + pingAdmin := router.Group("/ping-admin") + { + pingAdmin.GET("", middleware.ProtectedRoute, middleware.AdminRoute, func(ctx *gin.Context) { handlers.PingHandlerAdmin(ctx) }) } api := router.Group("/api") { - api.GET("/resource-auth", middleware.ProtectedRoute, func(ctx *gin.Context) { handlers.FetchResourceAuth(ctx, appsession) }) // authenticated + // resource-auth serves as an example for adding authentication to a route, remove when not needed + api.GET("/resource-auth", middleware.ProtectedRoute, func(ctx *gin.Context) { handlers.FetchResourceAuth(ctx, appsession) }) + // resource-auth-admin serves as an example for adding authentication as well as protecting admin routes, remove when not needed + api.GET("/resource-auth-admin", middleware.ProtectedRoute, middleware.AdminRoute, func(ctx *gin.Context) { handlers.FetchResourceAuth(ctx, appsession) }) api.POST("/book-room", middleware.ProtectedRoute, func(ctx *gin.Context) { handlers.BookRoom(ctx, appsession) }) api.POST("/check-in", middleware.ProtectedRoute, func(ctx *gin.Context) { handlers.CheckIn(ctx, appsession) }) - api.POST("cancel-booking", middleware.ProtectedRoute, func(ctx *gin.Context) { handlers.CancelBooking(ctx, appsession) }) - api.GET(("view-bookings"), middleware.ProtectedRoute, func(ctx *gin.Context) { handlers.ViewBookings(ctx, appsession) }) + api.POST("/cancel-booking", middleware.ProtectedRoute, func(ctx *gin.Context) { handlers.CancelBooking(ctx, appsession) }) + api.GET(("/view-bookings"), middleware.ProtectedRoute, func(ctx *gin.Context) { handlers.ViewBookings(ctx, appsession) }) api.GET("/view-rooms", middleware.ProtectedRoute, func(ctx *gin.Context) { handlers.ViewRooms(ctx, appsession) }) } auth := router.Group("/auth") { - auth.POST("/login", middleware.UnProtectedRoute, func(ctx *gin.Context) { handlers.Login(ctx, appsession) }) + auth.POST("/login", middleware.UnProtectedRoute, func(ctx *gin.Context) { handlers.Login(ctx, appsession, constants.Basic) }) + auth.POST("/login-admin", middleware.UnProtectedRoute, func(ctx *gin.Context) { handlers.Login(ctx, appsession, constants.Admin) }) auth.POST("/register", middleware.UnProtectedRoute, func(ctx *gin.Context) { handlers.Register(ctx, appsession) }) auth.POST("/verify-otp", middleware.UnProtectedRoute, func(ctx *gin.Context) { handlers.VerifyOTP(ctx, appsession) }) - // auth.POST("/logout", middleware.UnProtectedRoute, func(ctx *gin.Context) { handlers.Logout(ctx) }) - auth.GET("/callback", middleware.UnProtectedRoute, func(ctx *gin.Context) { handlers.CallbackHandler(ctx, appsession) }) + auth.POST("/logout", middleware.ProtectedRoute, func(ctx *gin.Context) { handlers.Logout(ctx) }) + // auth.POST("/reset-password", middleware.UnProtectedRoute, func(ctx *gin.Context) { handlers.ForgotPassword(ctx) }) } } diff --git a/occupi-backend/pkg/utils/utils.go b/occupi-backend/pkg/utils/utils.go index 814193a8..1f1dff29 100644 --- a/occupi-backend/pkg/utils/utils.go +++ b/occupi-backend/pkg/utils/utils.go @@ -153,8 +153,3 @@ func CompareArgon2IDHash(password string, hashedPassword string) (bool, error) { } return match, nil } - -func WillRemove() { - // This function is only here to make sure that the package is not empty - // and that the linter does not complain about it -} diff --git a/occupi-backend/tests/authenticator_test.go b/occupi-backend/tests/authenticator_test.go new file mode 100644 index 00000000..7a706377 --- /dev/null +++ b/occupi-backend/tests/authenticator_test.go @@ -0,0 +1,62 @@ +package tests + +import ( + "testing" + "time" + + "github.com/joho/godotenv" + + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/authenticator" + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/constants" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateToken(t *testing.T) { + // Load environment variables from .env file + if err := godotenv.Load("../.env"); err != nil { + t.Fatal("Error loading .env file: ", err) + } + + email := "test@example.com" + role := constants.Admin + tokenString, expirationTime, err := authenticator.GenerateToken(email, role) + + require.NoError(t, err) + require.NotEmpty(t, tokenString) + require.WithinDuration(t, time.Now().Add(5*time.Minute), expirationTime, time.Second) + + // Validate the token + claims, err := authenticator.ValidateToken(tokenString) + require.NoError(t, err) + require.NotNil(t, claims) + assert.Equal(t, email, claims.Email) + assert.Equal(t, role, claims.Role) +} + +func TestValidateToken(t *testing.T) { + // Load environment variables from .env file + if err := godotenv.Load("../.env"); err != nil { + t.Fatal("Error loading .env file: ", err) + } + + email := "test@example.com" + role := constants.Admin + tokenString, _, err := authenticator.GenerateToken(email, role) + + require.NoError(t, err) + require.NotEmpty(t, tokenString) + + // Validate the token + claims, err := authenticator.ValidateToken(tokenString) + require.NoError(t, err) + require.NotNil(t, claims) + assert.Equal(t, email, claims.Email) + assert.Equal(t, role, claims.Role) + + // Test with an invalid token + invalidTokenString := "invalid_token" + claims, err = authenticator.ValidateToken(invalidTokenString) + require.Error(t, err) + assert.Nil(t, claims) +} diff --git a/occupi-backend/tests/handlers_test.go b/occupi-backend/tests/handlers_test.go index 6423e529..0cb9bf8b 100644 --- a/occupi-backend/tests/handlers_test.go +++ b/occupi-backend/tests/handlers_test.go @@ -14,13 +14,10 @@ import ( "github.com/gin-gonic/gin" - "github.com/COS301-SE-2024/occupi/occupi-backend/configs" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/database" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/middleware" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/router" "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils" - // "github.com/joho/godotenv" - // "github.com/stretchr/testify/assert" // "github.com/stretchr/testify/mock" ) @@ -159,7 +156,7 @@ func TestPingRoute(t *testing.T) { db := database.ConnectToDatabase() // set gin run mode - gin.SetMode(configs.GetGinRunMode()) + gin.SetMode("test") // Create a Gin router ginRouter := gin.Default() @@ -216,7 +213,7 @@ func TestRateLimit(t *testing.T) { db := database.ConnectToDatabase() // set gin run mode - gin.SetMode(configs.GetGinRunMode()) + gin.SetMode("test") // Create a Gin router ginRouter := gin.Default() @@ -275,7 +272,7 @@ func TestRateLimitWithMultipleIPs(t *testing.T) { db := database.ConnectToDatabase() // set gin run mode - gin.SetMode(configs.GetGinRunMode()) + gin.SetMode("test") // Create a Gin router ginRouter := gin.Default() @@ -364,42 +361,3 @@ func TestRateLimitWithMultipleIPs(t *testing.T) { // Assertions for IP2 assert.Equal(t, rateLimitedCountIP2, 0, "There should be no requests from IP2 that are rate limited") } - -func TestGetResource(t *testing.T) { - // Load environment variables from .env file - if err := godotenv.Load("../.env"); err != nil { - t.Fatal("Error loading .env file: ", err) - } - - // setup logger to log all server interactions - utils.SetupLogger() - - // connect to the database - db := database.ConnectToDatabase() - - // set gin run mode - gin.SetMode(configs.GetGinRunMode()) - - // Create a Gin router - r := gin.Default() - - // Register the route - router.OccupiRouter(r, db) - - // Create a request to pass to the handler - req, err := http.NewRequest("GET", "/api/resource", nil) - if err != nil { - t.Fatal(err) - } - - // Create a response recorder to record the response - rr := httptest.NewRecorder() - - // Serve the request - r.ServeHTTP(rr, req) - - // Check the status code is what we expect. - if status := rr.Code; status != http.StatusNotFound { - t.Errorf("handler returned wrong status code: got %v want %v", status, http.StatusNotFound) - } -} diff --git a/occupi-backend/tests/middleware_test.go b/occupi-backend/tests/middleware_test.go new file mode 100644 index 00000000..6e07bf60 --- /dev/null +++ b/occupi-backend/tests/middleware_test.go @@ -0,0 +1,157 @@ +package tests + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/joho/godotenv" + "github.com/stretchr/testify/assert" + + "github.com/gin-gonic/gin" + + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/authenticator" + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/constants" + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/database" + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/router" + "github.com/COS301-SE-2024/occupi/occupi-backend/pkg/utils" + // "github.com/stretchr/testify/mock" +) + +func TestProtectedRoute(t *testing.T) { + // Load environment variables from .env file + if err := godotenv.Load("../.env"); err != nil { + t.Fatal("Error loading .env file: ", err) + } + + // setup logger to log all server interactions + utils.SetupLogger() + + // connect to the database + db := database.ConnectToDatabase() + + // set gin run mode + gin.SetMode("test") + + // Create a Gin router + r := gin.Default() + + // Register the route + router.OccupiRouter(r, db) + + token, _, _ := authenticator.GenerateToken("test@example.com", constants.Basic) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/ping-auth", nil) + req.AddCookie(&http.Cookie{Name: "token", Value: token}) + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal( + t, + "{\"data\":null,\"message\":\"pong -> I am alive and kicking and you are auth'd\",\"status\":200}", + strings.ReplaceAll(w.Body.String(), "-\\u003e", "->"), + ) +} + +func TestAdminRoute(t *testing.T) { + // Load environment variables from .env file + if err := godotenv.Load("../.env"); err != nil { + t.Fatal("Error loading .env file: ", err) + } + + // setup logger to log all server interactions + utils.SetupLogger() + + // connect to the database + db := database.ConnectToDatabase() + + // set gin run mode + gin.SetMode("test") + + // Create a Gin router + r := gin.Default() + + // Register the route + router.OccupiRouter(r, db) + + token, _, _ := authenticator.GenerateToken("admin@example.com", constants.Admin) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/ping-admin", nil) + req.AddCookie(&http.Cookie{Name: "token", Value: token}) + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal( + t, + "{\"data\":null,\"message\":\"pong -> I am alive and kicking and you are an admin\",\"status\":200}", + strings.ReplaceAll(w.Body.String(), "-\\u003e", "->"), + ) +} + +func TestUnauthorizedAccess(t *testing.T) { + // Load environment variables from .env file + if err := godotenv.Load("../.env"); err != nil { + t.Fatal("Error loading .env file: ", err) + } + + // setup logger to log all server interactions + utils.SetupLogger() + + // connect to the database + db := database.ConnectToDatabase() + + // set gin run mode + gin.SetMode("test") + + // Create a Gin router + r := gin.Default() + + // Register the route + router.OccupiRouter(r, db) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/ping-auth", nil) + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, "{\"error\":{\"code\":\"INVALID_AUTH\",\"details\":null,\"message\":\"User not authorized\"},\"message\":\"Bad Request\",\"status\":401}", w.Body.String()) +} + +func TestUnauthorizedAdminAccess(t *testing.T) { + // Load environment variables from .env file + if err := godotenv.Load("../.env"); err != nil { + t.Fatal("Error loading .env file: ", err) + } + + // setup logger to log all server interactions + utils.SetupLogger() + + // connect to the database + db := database.ConnectToDatabase() + + // set gin run mode + gin.SetMode("test") + + // Create a Gin router + r := gin.Default() + + // Register the route + router.OccupiRouter(r, db) + + token, _, _ := authenticator.GenerateToken("test@example.com", constants.Basic) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/ping-admin", nil) + req.AddCookie(&http.Cookie{Name: "token", Value: token}) + + r.ServeHTTP(w, req) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + assert.Equal(t, "{\"error\":{\"code\":\"INVALID_AUTH\",\"details\":null,\"message\":\"User not authorized to access admin route\"},\"message\":\"Bad Request\",\"status\":401}", w.Body.String()) +}