mirror of
https://github.com/coalaura/whiskr.git
synced 2025-09-07 08:15:31 +00:00
authentication
This commit is contained in:
@@ -24,11 +24,11 @@ whiskr is a private, self-hosted web chat interface for interacting with AI mode
|
||||
- Structured JSON output
|
||||
- Statistics for messages (provider, ttft, tps and token count)
|
||||
- Import and export of chats as JSON files
|
||||
- Authentication (optional)
|
||||
|
||||
## TODO
|
||||
|
||||
- Image and file attachments
|
||||
- Authentication
|
||||
|
||||
## Built With
|
||||
|
||||
|
125
auth.go
Normal file
125
auth.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type AuthenticationRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (u *EnvUser) Signature(secret string) []byte {
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
|
||||
mac.Write([]byte(u.Password))
|
||||
mac.Write([]byte(u.Username))
|
||||
|
||||
return mac.Sum(nil)
|
||||
}
|
||||
|
||||
func (e *Environment) Authenticate(username, password string) *EnvUser {
|
||||
user, ok := e.Authentication.lookup[username]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func (e *Environment) SignAuthToken(user *EnvUser) string {
|
||||
signature := user.Signature(e.Tokens.Secret)
|
||||
|
||||
return user.Username + ":" + hex.EncodeToString(signature)
|
||||
}
|
||||
|
||||
func (e *Environment) VerifyAuthToken(token string) bool {
|
||||
index := strings.Index(token, ":")
|
||||
if index == -1 {
|
||||
return false
|
||||
}
|
||||
|
||||
username := token[:index]
|
||||
|
||||
user, ok := e.Authentication.lookup[username]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
signature, err := hex.DecodeString(token[index+1:])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
expected := user.Signature(e.Tokens.Secret)
|
||||
|
||||
return hmac.Equal(signature, expected)
|
||||
}
|
||||
|
||||
func IsAuthenticated(r *http.Request) bool {
|
||||
if !env.Authentication.Enabled {
|
||||
return true
|
||||
}
|
||||
|
||||
cookie, err := r.Cookie("whiskr_token")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return env.VerifyAuthToken(cookie.Value)
|
||||
}
|
||||
|
||||
func Authenticate(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !IsAuthenticated(r) {
|
||||
RespondJson(w, http.StatusUnauthorized, map[string]any{
|
||||
"error": "unauthorized",
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func HandleAuthentication(w http.ResponseWriter, r *http.Request) {
|
||||
var request AuthenticationRequest
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
RespondJson(w, http.StatusBadRequest, map[string]any{
|
||||
"error": "missing username or password",
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
user := env.Authenticate(request.Username, request.Password)
|
||||
if user == nil {
|
||||
RespondJson(w, http.StatusUnauthorized, map[string]any{
|
||||
"error": "invalid username or password",
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "whiskr_token",
|
||||
Value: env.SignAuthToken(user),
|
||||
})
|
||||
|
||||
RespondJson(w, http.StatusOK, map[string]any{
|
||||
"authenticated": true,
|
||||
})
|
||||
}
|
18
env.go
18
env.go
@@ -9,7 +9,6 @@ import (
|
||||
"os"
|
||||
|
||||
"github.com/goccy/go-yaml"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type EnvTokens struct {
|
||||
@@ -29,8 +28,10 @@ type EnvUser struct {
|
||||
}
|
||||
|
||||
type EnvAuthentication struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Users []EnvUser `json:"users"`
|
||||
lookup map[string]*EnvUser
|
||||
|
||||
Enabled bool `json:"enabled"`
|
||||
Users []*EnvUser `json:"users"`
|
||||
}
|
||||
|
||||
type Environment struct {
|
||||
@@ -94,17 +95,14 @@ func (e *Environment) Init() error {
|
||||
log.Warning("Missing token.exa, web search unavailable")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
// create user lookup map
|
||||
e.Authentication.lookup = make(map[string]*EnvUser)
|
||||
|
||||
func (e *Environment) Authenticate(username, password string) bool {
|
||||
for _, user := range e.Authentication.Users {
|
||||
if user.Username == username {
|
||||
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil
|
||||
}
|
||||
e.Authentication.lookup[user.Username] = user
|
||||
}
|
||||
|
||||
return false
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Environment) Store() error {
|
||||
|
18
main.go
18
main.go
@@ -34,14 +34,22 @@ func main() {
|
||||
|
||||
r.Get("/-/data", func(w http.ResponseWriter, r *http.Request) {
|
||||
RespondJson(w, http.StatusOK, map[string]any{
|
||||
"version": Version,
|
||||
"search": env.Tokens.Exa != "",
|
||||
"models": models,
|
||||
"authentication": env.Authentication.Enabled,
|
||||
"authenticated": IsAuthenticated(r),
|
||||
"search": env.Tokens.Exa != "",
|
||||
"models": models,
|
||||
"version": Version,
|
||||
})
|
||||
})
|
||||
|
||||
r.Get("/-/stats/{id}", HandleStats)
|
||||
r.Post("/-/chat", HandleChat)
|
||||
r.Post("/-/auth", HandleAuthentication)
|
||||
|
||||
r.Group(func(gr chi.Router) {
|
||||
gr.Use(Authenticate)
|
||||
|
||||
gr.Get("/-/stats/{id}", HandleStats)
|
||||
gr.Post("/-/chat", HandleChat)
|
||||
})
|
||||
|
||||
log.Info("Listening at http://localhost:3443/")
|
||||
http.ListenAndServe(":3443", r)
|
||||
|
@@ -605,6 +605,7 @@ select {
|
||||
}
|
||||
|
||||
body.loading #version,
|
||||
.modal.loading .content::after,
|
||||
.reasoning .toggle::before,
|
||||
.reasoning .toggle::after,
|
||||
#bottom,
|
||||
@@ -801,6 +802,129 @@ label[for="reasoning-tokens"] {
|
||||
background-image: url(icons/stop.svg);
|
||||
}
|
||||
|
||||
.modal .background,
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.modal:not(.open) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal .background {
|
||||
background: rgba(24, 25, 38, 0.25);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.modal .content,
|
||||
.modal .body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal .content {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
gap: 14px;
|
||||
background: #24273a;
|
||||
padding: 18px 20px;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0px 0px 15px 5px rgba(0, 0, 0, 0.1);
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.modal .header {
|
||||
background: #363a4f;
|
||||
height: 42px;
|
||||
margin: -18px -20px;
|
||||
padding: 10px 20px;
|
||||
margin-bottom: 0;
|
||||
font-weight: 500;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.modal.loading .content::after,
|
||||
.modal.loading .content::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 42px;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.modal.loading .content::before {
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal.loading .content::after {
|
||||
top: 85px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
animation: rotating 1.2s linear infinite;
|
||||
background-image: url(icons/spinner.svg);
|
||||
}
|
||||
|
||||
.modal .error {
|
||||
background: #ed8796;
|
||||
font-weight: 500;
|
||||
font-style: italic;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14px;
|
||||
color: #11111b;
|
||||
padding: 5px 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.modal:not(.errored) .error {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal.errored input {
|
||||
border: 1px solid #ed8796;
|
||||
}
|
||||
|
||||
.modal .form-group {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.modal .form-group label {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.modal .form-group input {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.modal .buttons {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
#login {
|
||||
background: #a6da95;
|
||||
color: #11111b;
|
||||
padding: 4px 10px;
|
||||
border-radius: 2px;
|
||||
font-weight: 500;
|
||||
transition: 150ms;
|
||||
}
|
||||
|
||||
#login:hover {
|
||||
background: #89bb77;
|
||||
}
|
||||
|
||||
@keyframes rotating {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
|
@@ -85,6 +85,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="authentication" class="modal">
|
||||
<div class="background"></div>
|
||||
<div class="content">
|
||||
<div class="header">Authentication</div>
|
||||
<div class="body">
|
||||
<div id="auth-error" class="error"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" name="username" id="username" placeholder="admin" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" id="password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<button id="login">Login</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="lib/highlight.min.js"></script>
|
||||
<script src="lib/marked.min.js"></script>
|
||||
<script src="lib/morphdom.min.js"></script>
|
||||
|
@@ -17,12 +17,19 @@
|
||||
$scrolling = document.getElementById("scrolling"),
|
||||
$export = document.getElementById("export"),
|
||||
$import = document.getElementById("import"),
|
||||
$clear = document.getElementById("clear");
|
||||
$clear = document.getElementById("clear"),
|
||||
$authentication = document.getElementById("authentication"),
|
||||
$authError = document.getElementById("auth-error"),
|
||||
$username = document.getElementById("username"),
|
||||
$password = document.getElementById("password"),
|
||||
$login = document.getElementById("login");
|
||||
|
||||
const messages = [],
|
||||
models = {},
|
||||
modelList = [];
|
||||
|
||||
let authToken;
|
||||
|
||||
let autoScrolling = false,
|
||||
searchAvailable = false,
|
||||
jsonMode = false,
|
||||
@@ -879,6 +886,30 @@
|
||||
);
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const username = $username.value.trim(),
|
||||
password = $password.value.trim();
|
||||
|
||||
if (!username || !password) {
|
||||
throw new Error("missing username or password");
|
||||
}
|
||||
|
||||
const data = await fetch("/-/auth", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
username: username,
|
||||
password: password,
|
||||
}),
|
||||
}).then((response) => response.json());
|
||||
|
||||
if (!data?.authenticated) {
|
||||
throw new Error(data.error || "authentication failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const data = await json("/-/data");
|
||||
|
||||
@@ -898,6 +929,11 @@
|
||||
// update search availability
|
||||
searchAvailable = data.search;
|
||||
|
||||
// show login modal
|
||||
if (data.authentication && !data.authenticated) {
|
||||
$authentication.classList.add("open");
|
||||
}
|
||||
|
||||
// render models
|
||||
$model.innerHTML = "";
|
||||
|
||||
@@ -924,7 +960,7 @@
|
||||
|
||||
function clearMessages() {
|
||||
while (messages.length) {
|
||||
console.log("delete", messages.length)
|
||||
console.log("delete", messages.length);
|
||||
messages[0].delete();
|
||||
}
|
||||
}
|
||||
@@ -1169,6 +1205,32 @@
|
||||
generate(true);
|
||||
});
|
||||
|
||||
$login.addEventListener("click", async () => {
|
||||
$authentication.classList.remove("errored");
|
||||
$authentication.classList.add("loading");
|
||||
|
||||
try {
|
||||
await login();
|
||||
|
||||
$authentication.classList.remove("open");
|
||||
} catch(err) {
|
||||
$authError.textContent =`Error: ${err.message}`;
|
||||
$authentication.classList.add("errored");
|
||||
|
||||
$password.value = "";
|
||||
}
|
||||
|
||||
$authentication.classList.remove("loading");
|
||||
});
|
||||
|
||||
$username.addEventListener("input", () => {
|
||||
$authentication.classList.remove("errored");
|
||||
});
|
||||
|
||||
$password.addEventListener("input", () => {
|
||||
$authentication.classList.remove("errored");
|
||||
});
|
||||
|
||||
$message.addEventListener("keydown", (event) => {
|
||||
if (!event.ctrlKey || event.key !== "Enter") {
|
||||
return;
|
||||
|
Reference in New Issue
Block a user