diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/login_test.go | 68 | ||||
| -rw-r--r-- | web/static/login.html | 151 | ||||
| -rw-r--r-- | web/web.go | 2 |
3 files changed, 200 insertions, 21 deletions
diff --git a/web/login_test.go b/web/login_test.go new file mode 100644 index 0000000..b48e7bc --- /dev/null +++ b/web/login_test.go @@ -0,0 +1,68 @@ +package web + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "golang.org/x/crypto/bcrypt" + + "adammathes.com/neko/config" +) + +func TestCSRFLoginExclusion(t *testing.T) { + // Setup password + pw := "secret" + hashed, _ := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + // We need to set the global config since loginHandler uses it + config.Config.DigestPassword = string(hashed) // wait, config stores the hashed password? + // let's check web.go loginHandler: + // if password == config.Config.DigestPassword { + // v, _ := bcrypt.GenerateFromPassword([]byte(password), 0) + + // Ah, it compares PLAIN TEXT password with config.Config.DigestPassword. + // wait, line 113: if password == config.Config.DigestPassword { + // So config stores the PLAIN TEXT password? That's... odd, but okay for this test. + originalPw := config.Config.DigestPassword + defer func() { config.Config.DigestPassword = originalPw }() + config.Config.DigestPassword = pw + + // Create a mux with login handler + mux := http.NewServeMux() + mux.HandleFunc("/login/", loginHandler) + mux.HandleFunc("/other", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + + // Wrap with CSRF middleware + // We need to pass a pointer to Settings + handler := CSRFMiddleware(&config.Config, mux) + + // Test 1: POST /login/ without CSRF token + // Should NOT return 403 Forbidden. + // Since we provide correct password, it should redirect (307) + req := httptest.NewRequest("POST", "/login/", strings.NewReader("password="+pw)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + if rr.Code == http.StatusForbidden { + t.Errorf("Expected /login/ POST to be allowed without CSRF token, got 403 Forbidden") + } + if rr.Code != http.StatusTemporaryRedirect { + t.Errorf("Expected 307 Redirect on successful login, got %d", rr.Code) + } + + // Test 2: POST /other without CSRF token + // Should fail with 403 Forbidden + req2 := httptest.NewRequest("POST", "/other", nil) + rr2 := httptest.NewRecorder() + + handler.ServeHTTP(rr2, req2) + + if rr2.Code != http.StatusForbidden { + t.Errorf("Expected 403 Forbidden for POST /other, got %d", rr2.Code) + } +} diff --git a/web/static/login.html b/web/static/login.html index ab3e781..c7d0a03 100644 --- a/web/static/login.html +++ b/web/static/login.html @@ -1,23 +1,134 @@ <!DOCTYPE html> -<html xmlns="http://www.w3.org/1999/xhtml"> - <head> - <title>neko rss mode</title> - <link rel="stylesheet" href="/static/style.css" /> - <meta name="viewport" content="width=device-width" /> - <meta name="viewport" content="initial-scale=1.0" /> - </head> - <body> - - <form action="/login/" method="post"> - <h3>username</h3> - <input type="text" name="username"> - - <h3>password</h3> - <input type="password" name="password"> - <input type="submit" name="login"> - - </form> - - </body> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Neko Reader Login</title> + <style> + :root { + --bg-color: #f5f5f7; + --card-bg: #ffffff; + --text-color: #1d1d1f; + --border-color: #d2d2d7; + --primary-color: #0071e3; + --primary-hover: #0077ed; + --error-color: #ff3b30; + } + body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; + background-color: var(--bg-color); + color: var(--text-color); + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + margin: 0; + padding: 20px; + } + + .login-card { + background-color: var(--card-bg); + padding: 40px; + border-radius: 12px; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 360px; + text-align: center; + } + + h1 { + font-size: 24px; + font-weight: 600; + margin-bottom: 24px; + margin-top: 0; + } + + .input-group { + margin-bottom: 16px; + text-align: left; + } + + label { + display: block; + margin-bottom: 8px; + font-size: 14px; + font-weight: 500; + color: #86868b; + } + + input[type="text"], + input[type="password"] { + width: 100%; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + font-size: 16px; + box-sizing: border-box; + transition: border-color 0.2s ease; + } + + input[type="text"]:focus, + input[type="password"]:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba(0, 113, 227, 0.2); + } + + button { + width: 100%; + padding: 12px; + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.2s ease; + margin-top: 8px; + } + + button:hover { + background-color: var(--primary-hover); + } + + .footer { + margin-top: 24px; + font-size: 12px; + color: #86868b; + } + + /* Dark mode support */ + @media (prefers-color-scheme: dark) { + :root { + --bg-color: #000000; + --card-bg: #1c1c1e; + --text-color: #f5f5f7; + --border-color: #38383a; + --primary-color: #0a84ff; + --primary-hover: #409cff; + } + } + </style> +</head> +<body> + <div class="login-card"> + <h1>Welcome Back</h1> + <form action="/login/" method="post"> + <div class="input-group"> + <label for="username">Username</label> + <input type="text" id="username" name="username" required autofocus autocomplete="username"> + </div> + <div class="input-group"> + <label for="password">Password</label> + <input type="password" id="password" name="password" required autocomplete="current-password"> + </div> + <button type="submit">Sign In</button> + </form> + <div class="footer"> + Neko RSS Reader + </div> + </div> +</body> </html> @@ -379,7 +379,7 @@ func CSRFMiddleware(cfg *config.Settings, next http.Handler) http.Handler { token = cookie.Value } - if (r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete) && r.URL.Path != "/api/login" { + if (r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodDelete) && r.URL.Path != "/api/login" && r.URL.Path != "/login/" { headerToken := r.Header.Get("X-CSRF-Token") if headerToken == "" || headerToken != token { http.Error(w, "CSRF token mismatch", http.StatusForbidden) |
