finish implementing authentication

Amolith created

Change summary

cmd/cli.go          |   2 
cmd/willow.go       |   1 
project/project.go  |   1 
rss/rss.go          |   7 +-
users/users.go      |  39 +++++++++++----
ws/static/home.html |   3 +
ws/static/new.html  |   4 
ws/ws.go            | 114 +++++++++++++++++++++++++++++++++++++++++++++-
8 files changed, 150 insertions(+), 21 deletions(-)

Detailed changes

cmd/cli.go 🔗

@@ -94,7 +94,7 @@ func checkAuthorised(dbConn *sql.DB, username string) {
 	}
 	fmt.Println()
 
-	authorised, err := users.Authorised(dbConn, username, string(password))
+	authorised, err := users.UserAuthorised(dbConn, username, string(password))
 	if err != nil {
 		fmt.Println("Error checking authorisation:", err)
 		os.Exit(1)

cmd/willow.go 🔗

@@ -109,6 +109,7 @@ func main() {
 	mux.HandleFunc("/static", ws.StaticHandler)
 	mux.HandleFunc("/new", wsHandler.NewHandler)
 	mux.HandleFunc("/login", wsHandler.LoginHandler)
+	mux.HandleFunc("/logout", wsHandler.LogoutHandler)
 
 	httpServer := &http.Server{
 		Addr:    config.Server.Listen,

project/project.go 🔗

@@ -64,6 +64,7 @@ func fetchReleases(p Project) (Project, error) {
 	case "github", "gitea", "forgejo":
 		rssReleases, err := rss.GetReleases(p.URL)
 		if err != nil {
+			fmt.Println("Error getting RSS releases:", err)
 			return p, err
 		}
 		for _, release := range rssReleases {

rss/rss.go 🔗

@@ -6,6 +6,7 @@ package rss
 
 import (
 	"fmt"
+	"strings"
 	"time"
 
 	"github.com/microcosm-cc/bluemonday"
@@ -27,7 +28,8 @@ var (
 
 func GetReleases(feedURL string) ([]Release, error) {
 	fp := gofeed.NewParser()
-	feed, err := fp.ParseURL(feedURL + "/releases.atom")
+
+	feed, err := fp.ParseURL(strings.TrimSuffix(feedURL, "/") + "/releases.atom")
 	if err != nil {
 		fmt.Println(err)
 		return nil, err
@@ -44,8 +46,5 @@ func GetReleases(feedURL string) ([]Release, error) {
 		})
 	}
 
-	// TODO: Doesn't seem to work?
-	// sort.Slice(p.Releases, func(i, j int) bool { return p.Releases[i].Date.After(p.Releases[j].Date) })
-
 	return releases, nil
 }

users/users.go 🔗

@@ -55,9 +55,9 @@ func Register(dbConn *sql.DB, username, password string) error {
 // Delete removes a user from the database.
 func Delete(dbConn *sql.DB, username string) error { return db.DeleteUser(dbConn, username) }
 
-// Authorised accepts a username string, a token string, and returns true if the
+// UserAuthorised accepts a username string, a token string, and returns true if the
 // user is authorised, false if not, and an error if one is encountered.
-func Authorised(dbConn *sql.DB, username, token string) (bool, error) {
+func UserAuthorised(dbConn *sql.DB, username, token string) (bool, error) {
 	dbHash, dbSalt, err := db.GetUser(dbConn, username)
 	if err != nil {
 		return false, err
@@ -71,21 +71,38 @@ func Authorised(dbConn *sql.DB, username, token string) (bool, error) {
 	return dbHash == providedHash, nil
 }
 
-// GetSession accepts a session cookie string and returns the username
-func GetSession(dbConn *sql.DB, session string) (string, time.Time, error) {
-	return db.GetSession(dbConn, session)
+// SessionAuthorised accepts a session string and returns true if the session is
+// valid and false if not.
+func SessionAuthorised(dbConn *sql.DB, session string) (bool, error) {
+	dbResult, expiry, err := db.GetSession(dbConn, session)
+	if dbResult == "" || expiry.Before(time.Now()) || err != nil {
+		return false, err
+	}
+
+	return true, nil
 }
 
-// InvalidateSession invalidates a session by setting the expiration date to the
-// current time.
+// InvalidateSession invalidates a session by setting the expiration date to now.
 func InvalidateSession(dbConn *sql.DB, session string) error {
 	return db.InvalidateSession(dbConn, session, time.Now())
 }
 
-// CreateSession accepts a username and a token and creates a session in the
-// database.
-func CreateSession(dbConn *sql.DB, username, token string, expiry time.Time) error {
-	return db.CreateSession(dbConn, username, token, expiry)
+// CreateSession accepts a username, generates a token, stores it in the
+// database, and returns it
+func CreateSession(dbConn *sql.DB, username string) (string, time.Time, error) {
+	token, err := generateSalt()
+	if err != nil {
+		return "", time.Time{}, err
+	}
+
+	expiry := time.Now().Add(7 * 24 * time.Hour)
+
+	err = db.CreateSession(dbConn, username, token, expiry)
+	if err != nil {
+		return "", time.Time{}, err
+	}
+
+	return token, expiry, nil
 }
 
 // GetUsers returns a list of all users in the database as a slice of strings.

ws/static/home.html 🔗

@@ -22,6 +22,9 @@ html {
 }
 .project > h2 > span {
   float: right;
+}
+.project > details > pre {
+  overflow: scroll;
 }
     </style>
     </head>

ws/static/new.html 🔗

@@ -30,8 +30,8 @@ html {
                 <label for="github">Github</label><br>
                 <input type="radio" id="gitea" name="forge" value="gitea">
                 <label for="gitea">Gitea</label><br>
-                <input type="radio" id="Forgejo" name="forge" value="Forgejo">
-                <label for="Forgejo">Forgejo</label><br>
+                <input type="radio" id="forgejo" name="forge" value="forgejo">
+                <label for="forgejo">Forgejo</label><br>
                 <p>Raw git</p>
                 <input type="radio" id="gitlab" name="forge" value="gitlab">
                 <label for="gitlab">GitLab</label><br>

ws/ws.go 🔗

@@ -8,12 +8,14 @@ import (
 	"database/sql"
 	"embed"
 	"fmt"
+	"git.sr.ht/~amolith/willow/users"
 	"io"
 	"net/http"
 	"net/url"
 	"strings"
 	"sync"
 	"text/template"
+	"time"
 
 	"git.sr.ht/~amolith/willow/project"
 	"github.com/microcosm-cc/bluemonday"
@@ -153,12 +155,118 @@ func (h Handler) NewHandler(w http.ResponseWriter, r *http.Request) {
 }
 
 func (h Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
-	// TODO: do this
+	if r.Method == http.MethodGet {
+		if h.isAuthorised(r) {
+			http.Redirect(w, r, "/", http.StatusSeeOther)
+			return
+		}
+
+		login, err := fs.ReadFile("static/login.html")
+		if err != nil {
+			fmt.Println("Error reading login.html:", err)
+		}
+
+		if _, err := io.WriteString(w, string(login)); err != nil {
+			fmt.Println(err)
+		}
+	}
+
+	if r.Method == http.MethodPost {
+		err := r.ParseForm()
+		if err != nil {
+			fmt.Println(err)
+		}
+		username := bmStrict.Sanitize(r.FormValue("username"))
+		password := bmStrict.Sanitize(r.FormValue("password"))
+
+		if username == "" || password == "" {
+			w.WriteHeader(http.StatusBadRequest)
+			_, err := w.Write([]byte("No data provided"))
+			if err != nil {
+				fmt.Println(err)
+			}
+			return
+		}
+
+		authorised, err := users.UserAuthorised(h.DbConn, username, password)
+		if err != nil {
+			w.WriteHeader(http.StatusBadRequest)
+			_, err := w.Write([]byte(fmt.Sprintf("Error logging in: %s", err)))
+			if err != nil {
+				fmt.Println(err)
+			}
+			return
+		}
+
+		if !authorised {
+			w.WriteHeader(http.StatusUnauthorized)
+			_, err := w.Write([]byte("Incorrect username or password"))
+			if err != nil {
+				fmt.Println(err)
+			}
+			return
+		}
+
+		session, expiry, err := users.CreateSession(h.DbConn, username)
+		if err != nil {
+			w.WriteHeader(http.StatusBadRequest)
+			_, err := w.Write([]byte(fmt.Sprintf("Error creating session: %s", err)))
+			if err != nil {
+				fmt.Println(err)
+			}
+			return
+		}
+
+		maxAge := int(expiry.Sub(time.Now()).Seconds())
+
+		cookie := http.Cookie{
+			Name:     "id",
+			Value:    session,
+			MaxAge:   maxAge,
+			HttpOnly: true,
+			SameSite: http.SameSiteStrictMode,
+			Secure:   true,
+		}
+
+		http.SetCookie(w, &cookie)
+		http.Redirect(w, r, "/", http.StatusSeeOther)
+	}
 }
 
+func (h Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
+	cookie, err := r.Cookie("id")
+	if err != nil {
+		fmt.Println(err)
+	}
+
+	err = users.InvalidateSession(h.DbConn, cookie.Value)
+	if err != nil {
+		fmt.Println(err)
+		_, err = w.Write([]byte(fmt.Sprintf("Error logging out: %s", err)))
+		if err != nil {
+			fmt.Println(err)
+		}
+	}
+	cookie.MaxAge = -1
+	http.SetCookie(w, cookie)
+	http.Redirect(w, r, "/login", http.StatusSeeOther)
+}
+
+// isAuthorised makes a database request to the sessions table to see if the
+// user has a valid session cookie.
 func (h Handler) isAuthorised(r *http.Request) bool {
-	// TODO: do this
-	return false
+	cookie, err := r.Cookie("id")
+	if err != nil {
+		return false
+	}
+
+	authorised, err := users.SessionAuthorised(h.DbConn, cookie.Value)
+	if err != nil {
+		fmt.Println("Error checking session:", err)
+		return false
+	}
+
+	return authorised
 }
 
 func StaticHandler(writer http.ResponseWriter, request *http.Request) {