Add Websocket authentication #216

Signed-off-by: Michael Mayer <michael@liquidbytes.net>
This commit is contained in:
Michael Mayer 2020-01-22 16:54:01 +01:00
parent f569c3adb6
commit 7342d5194a
8 changed files with 194 additions and 33 deletions

View file

@ -15,7 +15,7 @@ const Notify = {
}, },
logout: function (message) { logout: function (message) {
Event.publish("notify.error", {msg: message}); Event.publish("notify.error", {msg: message});
Event.publish("session.logout"); Event.publish("session.logout", {msg: message});
}, },
ajaxStart: function() { ajaxStart: function() {
Event.publish("ajax.start"); Event.publish("ajax.start");

View file

@ -1,6 +1,7 @@
import Api from "./api"; import Api from "./api";
import Event from "pubsub-js"; import Event from "pubsub-js";
import User from "../model/user"; import User from "../model/user";
import Socket from "./websocket";
export default class Session { export default class Session {
/** /**
@ -24,7 +25,15 @@ export default class Session {
this.auth = true; this.auth = true;
} }
Event.subscribe("session.logout", this.onLogout.bind(this)); Event.subscribe("session.logout", () => {
this.onLogout()
});
Event.subscribe("websocket.connected", () => {
this.sendClientInfo()
});
this.sendClientInfo()
} }
useSessionStorage() { useSessionStorage() {
@ -118,6 +127,21 @@ export default class Session {
this.storage.removeItem("user"); this.storage.removeItem("user");
} }
sendClientInfo() {
const clientInfo = {
"session": this.getToken(),
"js": window.clientConfig.jsHash,
"css": window.clientConfig.cssHash,
"version": window.clientConfig.version,
};
try {
Socket.send(JSON.stringify(clientInfo));
} catch(e) {
console.log("can't send client info, websocket not connected (yet)")
}
}
login(email, password) { login(email, password) {
this.deleteToken(); this.deleteToken();
@ -125,11 +149,13 @@ export default class Session {
(result) => { (result) => {
this.setToken(result.data.token); this.setToken(result.data.token);
this.setUser(new User(result.data.user)); this.setUser(new User(result.data.user));
this.sendClientInfo();
} }
); );
} }
onLogout() { onLogout() {
console.log("ON LOGOUT");
this.deleteToken(); this.deleteToken();
window.location = "/"; window.location = "/";
} }

View file

@ -1,22 +1,15 @@
import Sockette from "sockette"; import Sockette from "sockette";
import Event from "pubsub-js"; import Event from "pubsub-js";
import randomString from "crypto-random-string";
export const token = randomString({length: 16});
const host = window.location.host; const host = window.location.host;
const prot = ("https:" === document.location.protocol ? "wss://" : "ws://"); const prot = ("https:" === document.location.protocol ? "wss://" : "ws://");
const url = prot + host + "/api/v1/ws"; const url = prot + host + "/api/v1/ws";
const clientInfo = {
"token": token,
"hash": window.clientConfig.jsHash,
"version": window.clientConfig.version,
};
const Socket = new Sockette(url, { const Socket = new Sockette(url, {
timeout: 5e3, timeout: 5e3,
onopen: e => { onopen: e => {
console.log("websocket: connected", e); console.log("websocket: connected", e);
Socket.send(JSON.stringify(clientInfo)); Event.publish("websocket.connected", e);
}, },
onmessage: e => { onmessage: e => {
const m = JSON.parse(e.data); const m = JSON.parse(e.data);

View file

@ -10,7 +10,7 @@
</v-toolbar> </v-toolbar>
<v-navigation-drawer <v-navigation-drawer
v-model="drawer" v-model="drawer"
:mini-variant="mini" :mini-variant="mini || !auth"
class="p-navigation-sidebar navigation" class="p-navigation-sidebar navigation"
width="270" width="270"
fixed dark app fixed dark app
@ -35,14 +35,14 @@
</v-list> </v-list>
</v-toolbar> </v-toolbar>
<v-list class="pt-3"> <v-list class="pt-3" v-if="auth">
<v-list-tile v-if="mini" @click.stop="mini = !mini" class="p-navigation-expand"> <v-list-tile v-if="mini" @click.stop="mini = !mini" class="p-navigation-expand">
<v-list-tile-action> <v-list-tile-action>
<v-icon>chevron_right</v-icon> <v-icon>chevron_right</v-icon>
</v-list-tile-action> </v-list-tile-action>
</v-list-tile> </v-list-tile>
<v-list-tile v-if="mini && auth" to="/photos" @click="" class="p-navigation-photos"> <v-list-tile v-if="mini" to="/photos" @click="" class="p-navigation-photos">
<v-list-tile-action> <v-list-tile-action>
<v-icon>photo</v-icon> <v-icon>photo</v-icon>
</v-list-tile-action> </v-list-tile-action>
@ -54,7 +54,7 @@
</v-list-tile-content> </v-list-tile-content>
</v-list-tile> </v-list-tile>
<v-list-group v-if="!mini && auth" prepend-icon="photo" no-action> <v-list-group v-if="!mini" prepend-icon="photo" no-action>
<v-list-tile slot="activator" to="/photos" @click.stop="" class="p-navigation-photos"> <v-list-tile slot="activator" to="/photos" @click.stop="" class="p-navigation-photos">
<v-list-tile-content> <v-list-tile-content>
<v-list-tile-title> <v-list-tile-title>
@ -89,7 +89,7 @@
</v-list-tile> </v-list-tile>
</v-list-group> </v-list-group>
<v-list-tile v-if="mini && auth" to="/archive" @click="" class="p-navigation-archive"> <v-list-tile v-if="mini" to="/archive" @click="" class="p-navigation-archive">
<v-list-tile-action> <v-list-tile-action>
<v-icon>archive</v-icon> <v-icon>archive</v-icon>
</v-list-tile-action> </v-list-tile-action>
@ -101,7 +101,7 @@
</v-list-tile-content> </v-list-tile-content>
</v-list-tile> </v-list-tile>
<v-list-tile v-if="mini && auth" to="/albums" @click=""> <v-list-tile v-if="mini" to="/albums" @click="">
<v-list-tile-action> <v-list-tile-action>
<v-icon>folder</v-icon> <v-icon>folder</v-icon>
</v-list-tile-action> </v-list-tile-action>
@ -113,7 +113,7 @@
</v-list-tile-content> </v-list-tile-content>
</v-list-tile> </v-list-tile>
<v-list-group v-if="!mini && auth" prepend-icon="folder" no-action> <v-list-group v-if="!mini" prepend-icon="folder" no-action>
<v-list-tile slot="activator" to="/albums" @click.stop=""> <v-list-tile slot="activator" to="/albums" @click.stop="">
<v-list-tile-content> <v-list-tile-content>
<v-list-tile-title> <v-list-tile-title>
@ -133,7 +133,7 @@
</v-list-tile> </v-list-tile>
</v-list-group> </v-list-group>
<v-list-tile to="/favorites" @click="" class="p-navigation-favorites" v-if="auth"> <v-list-tile to="/favorites" @click="" class="p-navigation-favorites">
<v-list-tile-action> <v-list-tile-action>
<v-icon>favorite</v-icon> <v-icon>favorite</v-icon>
</v-list-tile-action> </v-list-tile-action>
@ -146,7 +146,7 @@
</v-list-tile-content> </v-list-tile-content>
</v-list-tile> </v-list-tile>
<v-list-tile to="/labels" @click="" class="p-navigation-labels" v-if="auth"> <v-list-tile to="/labels" @click="" class="p-navigation-labels">
<v-list-tile-action> <v-list-tile-action>
<v-icon>label</v-icon> <v-icon>label</v-icon>
</v-list-tile-action> </v-list-tile-action>
@ -159,7 +159,7 @@
</v-list-tile-content> </v-list-tile-content>
</v-list-tile> </v-list-tile>
<v-list-tile :to="{ name: 'places' }" @click="" class="p-navigation-places" v-if="auth"> <v-list-tile :to="{ name: 'places' }" @click="" class="p-navigation-places">
<v-list-tile-action> <v-list-tile-action>
<v-icon>place</v-icon> <v-icon>place</v-icon>
</v-list-tile-action> </v-list-tile-action>
@ -172,7 +172,7 @@
</v-list-tile-content> </v-list-tile-content>
</v-list-tile> </v-list-tile>
<v-list-tile to="/discover" @click="" class="p-navigation-discover" v-if="config.experimental && auth"> <v-list-tile to="/discover" @click="" class="p-navigation-discover" v-if="config.experimental">
<v-list-tile-action> <v-list-tile-action>
<v-icon>color_lens</v-icon> <v-icon>color_lens</v-icon>
</v-list-tile-action> </v-list-tile-action>
@ -184,7 +184,7 @@
</v-list-tile-content> </v-list-tile-content>
</v-list-tile> </v-list-tile>
<!-- v-list-tile to="/events" @click="" class="p-navigation-events" v-if="auth"> <!-- v-list-tile to="/events" @click="" class="p-navigation-events">
<v-list-tile-action> <v-list-tile-action>
<v-icon>date_range</v-icon> <v-icon>date_range</v-icon>
</v-list-tile-action> </v-list-tile-action>
@ -194,7 +194,7 @@
</v-list-tile-content> </v-list-tile-content>
</v-list-tile --> </v-list-tile -->
<!-- v-list-tile to="/people" @click="" class="p-navigation-people" v-if="auth"> <!-- v-list-tile to="/people" @click="" class="p-navigation-people">
<v-list-tile-action> <v-list-tile-action>
<v-icon>people</v-icon> <v-icon>people</v-icon>
</v-list-tile-action> </v-list-tile-action>
@ -204,7 +204,7 @@
</v-list-tile-content> </v-list-tile-content>
</v-list-tile --> </v-list-tile -->
<v-list-tile to="/library" @click="" class="p-navigation-library" v-if="auth"> <v-list-tile to="/library" @click="" class="p-navigation-library">
<v-list-tile-action> <v-list-tile-action>
<v-icon>camera_roll</v-icon> <v-icon>camera_roll</v-icon>
</v-list-tile-action> </v-list-tile-action>
@ -216,7 +216,7 @@
</v-list-tile-content> </v-list-tile-content>
</v-list-tile> </v-list-tile>
<v-list-tile to="/settings" @click="" class="p-navigation-settings" v-if="auth"> <v-list-tile to="/settings" @click="" class="p-navigation-settings">
<v-list-tile-action> <v-list-tile-action>
<v-icon>settings</v-icon> <v-icon>settings</v-icon>
</v-list-tile-action> </v-list-tile-action>

View file

@ -5,6 +5,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/session" "github.com/photoprism/photoprism/internal/session"
"github.com/photoprism/photoprism/pkg/txt" "github.com/photoprism/photoprism/pkg/txt"
@ -33,6 +34,8 @@ func CreateSession(router *gin.RouterGroup, conf *config.Config) {
s := gin.H{"token": token, "user": user} s := gin.H{"token": token, "user": user}
event.Publish("config.updated", event.Data(conf.ClientConfig()))
c.JSON(http.StatusOK, s) c.JSON(http.StatusOK, s)
}) })
} }

View file

@ -1,13 +1,17 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"sync"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
"github.com/photoprism/photoprism/internal/config" "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/session"
"github.com/photoprism/photoprism/pkg/rnd"
) )
var wsConnection = websocket.Upgrader{ var wsConnection = websocket.Upgrader{
@ -17,7 +21,19 @@ var wsConnection = websocket.Upgrader{
} }
var wsTimeout = 60 * time.Second var wsTimeout = 60 * time.Second
func wsReader(ws *websocket.Conn) { type clientInfo struct {
SessionToken string `json:"session"`
JsHash string `json:"js"`
CssHash string `json:"css"`
Version string `json:"version"`
}
var wsAuth = struct {
authenticated map[string]bool
mutex sync.RWMutex
}{authenticated: make(map[string]bool)}
func wsReader(ws *websocket.Conn, connId string) {
defer ws.Close() defer ws.Close()
ws.SetReadLimit(512) ws.SetReadLimit(512)
@ -26,14 +42,30 @@ func wsReader(ws *websocket.Conn) {
for { for {
_, m, err := ws.ReadMessage() _, m, err := ws.ReadMessage()
if err != nil { if err != nil {
break break
} }
log.Debugf("websocket: received %d bytes", len(m)) log.Debugf("websocket: received %d bytes", len(m))
var info clientInfo
if err := json.Unmarshal(m, &info); err != nil {
log.Error(err)
} else {
log.Debugf("websocket: %+v", info)
if session.Exists(info.SessionToken) {
wsAuth.mutex.Lock()
wsAuth.authenticated[connId] = true
wsAuth.mutex.Unlock()
}
}
} }
} }
func wsWriter(ws *websocket.Conn) { func wsWriter(ws *websocket.Conn, connId string) {
pingTicker := time.NewTicker(10 * time.Second) pingTicker := time.NewTicker(10 * time.Second)
s := event.Subscribe("log.*", "notify.*", "index.*", "upload.*", "import.*", "config.*", "count.*") s := event.Subscribe("log.*", "notify.*", "index.*", "upload.*", "import.*", "config.*", "count.*")
@ -41,6 +73,10 @@ func wsWriter(ws *websocket.Conn) {
pingTicker.Stop() pingTicker.Stop()
event.Unsubscribe(s) event.Unsubscribe(s)
ws.Close() ws.Close()
wsAuth.mutex.Lock()
wsAuth.authenticated[connId] = false
wsAuth.mutex.Unlock()
}() }()
for { for {
@ -51,11 +87,17 @@ func wsWriter(ws *websocket.Conn) {
return return
} }
case msg := <-s.Receiver: case msg := <-s.Receiver:
ws.SetWriteDeadline(time.Now().Add(10 * time.Second)) wsAuth.mutex.RLock()
auth := wsAuth.authenticated[connId]
wsAuth.mutex.RUnlock()
if err := ws.WriteJSON(gin.H{"event": msg.Name, "data": msg.Fields}); err != nil { if auth {
log.Debug(err) ws.SetWriteDeadline(time.Now().Add(10 * time.Second))
return
if err := ws.WriteJSON(gin.H{"event": msg.Name, "data": msg.Fields}); err != nil {
log.Debug(err)
return
}
} }
} }
} }
@ -63,6 +105,16 @@ func wsWriter(ws *websocket.Conn) {
// GET /api/v1/ws // GET /api/v1/ws
func Websocket(router *gin.RouterGroup, conf *config.Config) { func Websocket(router *gin.RouterGroup, conf *config.Config) {
if router == nil {
log.Error("websocket: router is nil")
return
}
if conf == nil {
log.Error("websocket: conf is nil")
return
}
router.GET("/ws", func(c *gin.Context) { router.GET("/ws", func(c *gin.Context) {
w := c.Writer w := c.Writer
r := c.Request r := c.Request
@ -75,10 +127,18 @@ func Websocket(router *gin.RouterGroup, conf *config.Config) {
defer ws.Close() defer ws.Close()
connId := rnd.UUID()
if conf.Public() {
wsAuth.mutex.Lock()
wsAuth.authenticated[connId] = true
wsAuth.mutex.Unlock()
}
log.Debug("websocket: connected") log.Debug("websocket: connected")
go wsWriter(ws) go wsWriter(ws, connId)
wsReader(ws) wsReader(ws, connId)
}) })
} }

View file

@ -12,6 +12,84 @@ import (
// HTTP client / Web UI config values // HTTP client / Web UI config values
type ClientConfig map[string]interface{} type ClientConfig map[string]interface{}
// PublicClientConfig returns reduced config values for non-public sites.
func (c *Config) PublicClientConfig() ClientConfig {
if c.Public() {
return c.ClientConfig()
}
jsHash := fs.Hash(c.HttpStaticBuildPath() + "/app.js")
cssHash := fs.Hash(c.HttpStaticBuildPath() + "/app.css")
// Feature Flags
var flags []string
if c.Public() {
flags = append(flags, "public")
}
if c.Debug() {
flags = append(flags, "debug")
}
if c.Experimental() {
flags = append(flags, "experimental")
}
if c.ReadOnly() {
flags = append(flags, "readonly")
}
var noPos = struct {
PhotoUUID string `json:"photo"`
LocationID string `json:"location"`
TakenAt time.Time `json:"utc"`
PhotoLat float64 `json:"lat"`
PhotoLng float64 `json:"lng"`
}{}
var count = struct {
Photos uint `json:"photos"`
Favorites uint `json:"favorites"`
Private uint `json:"private"`
Stories uint `json:"stories"`
Labels uint `json:"labels"`
Albums uint `json:"albums"`
Countries uint `json:"countries"`
Places uint `json:"places"`
}{}
result := ClientConfig{
"flags": strings.Join(flags, " "),
"name": c.Name(),
"url": c.Url(),
"title": c.Title(),
"subtitle": c.Subtitle(),
"description": c.Description(),
"author": c.Author(),
"twitter": c.Twitter(),
"version": c.Version(),
"copyright": c.Copyright(),
"debug": c.Debug(),
"readonly": c.ReadOnly(),
"uploadNSFW": c.UploadNSFW(),
"public": c.Public(),
"experimental": c.Experimental(),
"albums": []string{},
"cameras": []string{},
"lenses": []string{},
"countries": []string{},
"thumbnails": Thumbnails,
"jsHash": jsHash,
"cssHash": cssHash,
"settings": c.Settings(),
"count": count,
"pos": noPos,
"years": []int{},
"colors": colors.All.List(),
"categories": []string{},
}
return result
}
// ClientConfig returns a loaded and set configuration entity. // ClientConfig returns a loaded and set configuration entity.
func (c *Config) ClientConfig() ClientConfig { func (c *Config) ClientConfig() ClientConfig {
db := c.Db() db := c.Db()

View file

@ -72,6 +72,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
// Default HTML page (client-side routing implemented via Vue.js) // Default HTML page (client-side routing implemented via Vue.js)
router.NoRoute(func(c *gin.Context) { router.NoRoute(func(c *gin.Context) {
// Todo: Use PublicClientConfig()
c.HTML(http.StatusOK, "index.tmpl", gin.H{"clientConfig": conf.ClientConfig()}) c.HTML(http.StatusOK, "index.tmpl", gin.H{"clientConfig": conf.ClientConfig()})
}) })
} }