Compare commits

...

4 Commits

Author SHA1 Message Date
Magnus Åhall
63a7370570 Bumped to v11, display version in web-UI 2023-07-20 10:52:15 +02:00
Magnus Åhall
db33be9a37 Password update 2023-07-20 10:47:49 +02:00
Magnus Åhall
48252de9f3 Search CSS 2023-07-20 10:07:47 +02:00
Magnus Åhall
54a0ee4f29 Hashed passwords. 2023-07-20 10:06:28 +02:00
8 changed files with 384 additions and 47 deletions

36
main.go
View File

@ -20,9 +20,9 @@ import (
_ "embed"
)
const VERSION = "v10";
const VERSION = "v11";
const LISTEN_HOST = "0.0.0.0";
const DB_SCHEMA = 12
const DB_SCHEMA = 13
var (
flagPort int
@ -68,6 +68,7 @@ func main() {// {{{
http.HandleFunc("/session/create", sessionCreate)
http.HandleFunc("/session/retrieve", sessionRetrieve)
http.HandleFunc("/session/authenticate", sessionAuthenticate)
http.HandleFunc("/user/password", userPassword)
http.HandleFunc("/node/tree", nodeTree)
http.HandleFunc("/node/retrieve", nodeRetrieve)
http.HandleFunc("/node/create", nodeCreate)
@ -201,6 +202,37 @@ func sessionAuthenticate(w http.ResponseWriter, r *http.Request) {// {{{
})
}// }}}
func userPassword(w http.ResponseWriter, r *http.Request) {// {{{
var err error
var ok bool
var session Session
if session, _, err = ValidateSession(r, true); err != nil {
responseError(w, err)
return
}
req := struct {
CurrentPassword string
NewPassword string
}{}
if err = parseRequest(r, &req); err != nil {
responseError(w, err)
return
}
ok, err = session.UpdatePassword(req.CurrentPassword, req.NewPassword)
if err != nil {
responseError(w, err)
return
}
responseData(w, map[string]interface{}{
"OK": true,
"CurrentPasswordOK": ok,
})
}// }}}
func nodeTree(w http.ResponseWriter, r *http.Request) {// {{{
var err error
var session Session

View File

@ -91,7 +91,7 @@ func (session *Session) Authenticate(username, password string) (authenticated b
FROM public.user
WHERE
username=$1 AND
password=$2
password=password_hash(SUBSTRING(password FROM 1 FOR 32), $2::bytea)
`,
username,
password,

34
sql/0013.sql Normal file
View File

@ -0,0 +1,34 @@
/* Required for the gen_random_bytes function */
CREATE EXTENSION pgcrypto;
CREATE FUNCTION password_hash(salt_hex char(32), pass bytea)
RETURNS char(96)
LANGUAGE plpgsql
AS
$$
BEGIN
RETURN (
SELECT
salt_hex ||
encode(
sha256(
decode(salt_hex, 'hex') || /* salt in binary */
pass /* password */
),
'hex'
)
);
END;
$$;
/* Password has to be able to accommodate 96 characters instead of previous 64.
* It can't be char(96), because then the password would be padded to 96 characters. */
ALTER TABLE public."user" ALTER COLUMN "password" TYPE varchar(96) USING "password"::varchar;
/* Update all users with salted and hashed passwords */
UPDATE public.user
SET password = password_hash( encode(gen_random_bytes(16),'hex'), password::bytea);
/* After the password hashing, all passwords are now hex encoded 32 characters salt and 64 characters hash,
* and the varchar type is not longer necessary. */
ALTER TABLE public."user" ALTER COLUMN "password" TYPE char(96) USING "password"::varchar;

View File

@ -55,6 +55,13 @@ button {
border: 2px solid #000;
box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.5);
z-index: 1025;
width: min-content;
}
#menu .section {
padding: 16px 16px 0px 16px;
font-weight: bold;
white-space: nowrap;
color: #c84a37;
}
#menu .item {
padding: 16px;
@ -137,7 +144,7 @@ button {
header {
display: grid;
grid-area: header;
grid-template-columns: min-content 1fr repeat(3, min-content);
grid-template-columns: min-content 1fr repeat(4, min-content);
align-items: center;
padding: 8px 0px;
color: #333c11;
@ -161,17 +168,13 @@ header .name {
padding-left: 16px;
font-size: 1.25em;
}
header .add {
padding-right: 16px;
cursor: pointer;
}
header .add img {
cursor: pointer;
height: 24px;
}
header .search,
header .add,
header .keys {
padding-right: 16px;
}
header .search img,
header .add img,
header .keys img {
cursor: pointer;
height: 24px;
@ -276,6 +279,11 @@ header .menu {
margin-bottom: 32px;
font-size: 1.5em;
}
#notes-version {
margin-top: 64px;
color: #888;
text-align: center;
}
#node-content.encrypted {
color: #a00;
}
@ -538,6 +546,19 @@ header .menu {
#app.node.toggle-tree #tree {
display: none;
}
#profile-settings {
color: #333;
padding: 16px;
}
#profile-settings .passwords {
display: grid;
grid-template-columns: min-content 200px;
grid-gap: 8px 16px;
margin-bottom: 16px;
}
#profile-settings .passwords div {
white-space: nowrap;
}
@media only screen and (max-width: 932px) {
#app.node {
grid-template-areas: "header" "crumbs" "child-nodes" "name" "content" "files" "blank";

107
static/images/search.svg Normal file
View File

@ -0,0 +1,107 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="191.20831"
height="227.46098"
viewBox="0 0 50.590532 60.182387"
version="1.1"
id="svg8"
inkscape:version="1.2.1 (9c6d41e, 2022-07-14)"
sodipodi:docname="search.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2">
<linearGradient
inkscape:collect="always"
id="linearGradient16049">
<stop
style="stop-color:#ffffff;stop-opacity:0.36503741;"
offset="0"
id="stop16045" />
<stop
style="stop-color:#ffffff;stop-opacity:0;"
offset="1"
id="stop16047" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient16049"
id="linearGradient16051"
x1="36.007183"
y1="8.6465521"
x2="12.402377"
y2="41.804993"
gradientUnits="userSpaceOnUse" />
</defs>
<sodipodi:namedview
id="base"
pagecolor="#5056d3"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="2.8284271"
inkscape:cx="153.08862"
inkscape:cy="145.664"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="2190"
inkscape:window-height="1404"
inkscape:window-x="1463"
inkscape:window-y="16"
inkscape:window-maximized="0"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d6d6d6"
showborder="true"
showguides="false">
<sodipodi:guide
position="57.608417,-11.948636"
orientation="0.81915204,0.57357644"
id="guide5717"
inkscape:locked="false"
inkscape:label=""
inkscape:color="rgb(0,134,229)" />
</sodipodi:namedview>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-2.3771159,-1.8407145)">
<path
style="color:#000000;overflow:visible;opacity:1;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:#ffffff;stroke-width:8.46667;stroke-linecap:round;stroke-dasharray:none;paint-order:markers stroke fill;stop-color:#000000"
d="m 38.808695,43.970762 9.925619,13.819002"
id="path2090" />
<path
id="circle4774"
style="color:#000000;overflow:visible;fill:#ffffff;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:3.175;paint-order:markers stroke fill;stop-color:#000000"
d="M 25.660449,1.8407145 A 23.283335,23.283335 0 0 0 2.3771159,25.124048 23.283335,23.283335 0 0 0 25.660449,48.407381 23.283335,23.283335 0 0 0 48.944299,25.124048 23.283335,23.283335 0 0 0 25.660449,1.8407145 Z m 0,6.35 A 16.933332,16.933332 0 0 1 42.593783,25.124048 16.933332,16.933332 0 0 1 25.660449,42.057381 16.933332,16.933332 0 0 1 8.7276327,25.124048 16.933332,16.933332 0 0 1 25.660449,8.1907145 Z" />
<circle
style="color:#000000;overflow:visible;fill:url(#linearGradient16051);fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:3.175;paint-order:markers stroke fill;stop-color:#000000"
id="path429"
cx="25.660707"
cy="25.123877"
r="16.933332" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@ -57,7 +57,7 @@ export class NodeUI extends Component {
case 'node':
if(node.ID == 0) {
page = html`
${children.length > 0 ? html`<div class="child-nodes">${children}</div>` : html``}
${children.length > 0 ? html`<div class="child-nodes">${children}</div><div id="notes-version">Notes version ${window._VERSION}</div>` : html``}
`
} else {
let padlock = ''
@ -86,6 +86,10 @@ export class NodeUI extends Component {
page = html`<${Keys} nodeui=${this} />`
break
case 'profile-settings':
page = html`<${ProfileSettings} nodeui=${this} />`
break
case 'search':
page = html`<${Search} nodeui=${this} />`
break
@ -100,6 +104,7 @@ export class NodeUI extends Component {
<header class="${modified}" onclick=${()=>this.saveNode()}>
<div class="tree"><img src="/images/${window._VERSION}/tree.svg" onclick=${()=>document.getElementById('app').classList.toggle('toggle-tree')} /></div>
<div class="name">Notes</div>
<div class="search" onclick=${evt=>{ evt.stopPropagation(); this.showPage('search')}}><img src="/images/${window._VERSION}/search.svg" /></div>
<div class="add" onclick=${evt=>this.createNode(evt)}><img src="/images/${window._VERSION}/add.svg" /></div>
<div class="keys" onclick=${evt=>{ evt.stopPropagation(); this.showPage('keys')}}><img src="/images/${window._VERSION}/padlock.svg" /></div>
<div class="menu" onclick=${evt=>this.showMenu(evt)}></div>
@ -213,7 +218,11 @@ export class NodeUI extends Component {
this.node.value.create(name, nodeID=>this.goToNode(nodeID))
}//}}}
saveNode() {//{{{
let content = this.nodeContent.current.contentDiv.current.value
let nodeContent = this.nodeContent.current
if(this.page.value != 'node' || nodeContent === null)
return
let content = nodeContent.contentDiv.current.value
this.node.value.setContent(content)
this.node.value.save(()=>this.props.app.nodeModified.value = false)
}//}}}
@ -547,10 +556,14 @@ class Menu extends Component {
return html`
<div id="blackout" onclick=${()=>nodeui.menu.value = false}></div>
<div id="menu">
<div class="section">Current note</div>
<div class="item" onclick=${()=>{ nodeui.renameNode(); nodeui.menu.value = false }}>Rename</div>
<div class="item" onclick=${()=>{ nodeui.deleteNode(); nodeui.menu.value = false }}>Delete</div>
<div class="item separator" onclick=${()=>{ nodeui.showPage('properties'); nodeui.menu.value = false }}>Properties</div>
<div class="item separator" onclick=${()=>{ nodeui.upload.value = true; nodeui.menu.value = false }}>Upload</div>
<div class="item" onclick=${()=>{ nodeui.upload.value = true; nodeui.menu.value = false }}>Upload</div>
<div class="item " onclick=${()=>{ nodeui.showPage('node-properties'); nodeui.menu.value = false }}>Properties</div>
<div class="item separator" onclick=${()=>{ nodeui.deleteNode(); nodeui.menu.value = false }}>Delete</div>
<div class="section">User</div>
<div class="item" onclick=${()=>{ nodeui.showPage('profile-settings'); nodeui.menu.value = false }}>Settings</div>
<div class="item" onclick=${()=>{ nodeui.logout(); nodeui.menu.value = false }}>Log out</div>
</div>
`
@ -788,4 +801,77 @@ class Search extends Component {
}//}}}
}
class ProfileSettings extends Component {
render({ nodeui }, {}) {//{{{
return html`
<div id="profile-settings">
<h1>User settings</h1>
<h2>Password</h2>
<div class="passwords">
<div>Current</div>
<input type="password" id="current-password" placeholder="Current password" onkeydown=${evt=>this.keyHandler(evt)} />
<div>New</div>
<input type="password" id="new-password1" placeholder="Password" onkeydown=${evt=>this.keyHandler(evt)} />
<div>Repeat</div>
<input type="password" id="new-password2" placeholder="Repeat password" onkeydown=${evt=>this.keyHandler(evt)} />
</div>
<button onclick=${()=>this.updatePassword()}>Change password</button>
</div>`
}//}}}
componentDidMount() {//{{{
document.getElementById('current-password').focus()
}//}}}
keyHandler(evt) {//{{{
let handled = true
switch(evt.key.toUpperCase()) {
case 'ENTER':
this.updatePassword()
break
default:
handled = false
}
if(handled) {
evt.preventDefault()
evt.stopPropagation()
}
}//}}}
updatePassword() {//{{{
let curr_pass = document.getElementById('current-password').value
let pass1 = document.getElementById('new-password1').value
let pass2 = document.getElementById('new-password2').value
try {
if(pass1.length < 4) {
throw new Error('Password has to be at least 4 characters long')
}
if(pass1 != pass2) {
throw new Error(`Passwords don't match`)
}
window._app.current.request('/user/password', {
CurrentPassword: curr_pass,
NewPassword: pass1,
})
.then(res=>{
if(res.CurrentPasswordOK)
alert('Password is changed successfully')
else
alert('Current password is invalid')
})
} catch(err) {
alert(err.message)
}
}//}}}
}
// vim: foldmethod=marker

View File

@ -56,6 +56,14 @@ button {
border: 2px solid #000;
box-shadow: 5px 5px 8px 0px rgba(0,0,0,0.5);
z-index: 1025;
width: min-content;
.section {
padding: 16px 16px 0px 16px;
font-weight: bold;
white-space: nowrap;
color: @accent_3;
}
.item {
padding: 16px;
@ -154,7 +162,7 @@ button {
header {
display: grid;
grid-area: header;
grid-template-columns: min-content 1fr repeat(3, min-content);
grid-template-columns: min-content 1fr repeat(4, min-content);
align-items: center;
padding: 8px 0px;
color: darken(@accent_1, 35%);
@ -181,17 +189,7 @@ header {
font-size: 1.25em;
}
.add {
padding-right: 16px;
cursor: pointer;
img {
cursor: pointer;
height: 24px;
}
}
.keys {
.search, .add, .keys {
padding-right: 16px;
img {
@ -324,6 +322,12 @@ header {
font-size: 1.5em;
}
#notes-version {
margin-top: 64px;
color: #888;
text-align: center;
}
#node-content.encrypted {
color: #a00;
}
@ -617,6 +621,22 @@ header {
}
#profile-settings {
color: #333;
padding: 16px;
.passwords {
display: grid;
grid-template-columns: min-content 200px;
grid-gap: 8px 16px;
margin-bottom: 16px;
div {
white-space: nowrap;
}
}
}
@media only screen and (max-width: 932px) {
#app.node {
.layout-crumbs();

37
user.go Normal file
View File

@ -0,0 +1,37 @@
package main
import (
// Standard
"database/sql"
)
func (session Session) UpdatePassword(currPass, newPass string) (ok bool, err error) {
var result sql.Result
var rowsAffected int64
result, err = db.Exec(`
UPDATE public.user
SET
password = password_hash(
/* salt in hex */
ENCODE(gen_random_bytes(16), 'hex'),
/* password */
$1::bytea
)
WHERE
id = $2 AND
password=password_hash(SUBSTRING(password FROM 1 FOR 32), $3::bytea)
RETURNING id
`,
newPass,
session.UserID,
currPass,
)
if rowsAffected, err = result.RowsAffected(); err != nil {
return
}
return rowsAffected > 0, nil
}