mirror of
https://github.com/gristlabs/grist-core.git
synced 2026-03-02 04:09:24 +00:00
(core) updates from grist-core
This commit is contained in:
@@ -145,6 +145,13 @@ Please log in as an administrator.`)),
|
||||
description: t('Current authentication method'),
|
||||
value: this._buildAuthenticationDisplay(owner),
|
||||
expandedContent: this._buildAuthenticationNotice(owner),
|
||||
}),
|
||||
dom.create(AdminSectionItem, {
|
||||
id: 'session',
|
||||
name: t('Session Secret'),
|
||||
description: t('Key to sign sessions with'),
|
||||
value: this._buildSessionSecretDisplay(owner),
|
||||
expandedContent: this._buildSessionSecretNotice(owner),
|
||||
})
|
||||
]),
|
||||
dom.create(AdminSection, t('Version'), [
|
||||
@@ -241,6 +248,27 @@ We recommend enabling one of these if Grist is accessible over the network or be
|
||||
to multiple people.');
|
||||
}
|
||||
|
||||
private _buildSessionSecretDisplay(owner: IDisposableOwner) {
|
||||
return dom.domComputed(
|
||||
use => {
|
||||
const req = this._checks.requestCheckById(use, 'session-secret');
|
||||
const result = req ? use(req.result) : undefined;
|
||||
|
||||
if (result?.status === 'warning') {
|
||||
return cssValueLabel(cssDangerText('default'));
|
||||
}
|
||||
|
||||
return cssValueLabel(cssHappyText('configured'));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private _buildSessionSecretNotice(owner: IDisposableOwner) {
|
||||
return t('Grist signs user session cookies with a secret key. Please set this key via the environment variable \
|
||||
GRIST_SESSION_SECRET. Grist falls back to a hard-coded default when it is not set. We may remove this notice \
|
||||
in the future as session IDs generated since v1.1.16 are inherently cryptographically secure.');
|
||||
}
|
||||
|
||||
private _buildUpdates(owner: MultiHolder) {
|
||||
// We can be in those states:
|
||||
enum State {
|
||||
@@ -472,7 +500,11 @@ to multiple people.');
|
||||
return dom.domComputed(
|
||||
use => [
|
||||
...use(this._checks.probes).map(probe => {
|
||||
const isRedundant = probe.id === 'sandboxing';
|
||||
const isRedundant = [
|
||||
'sandboxing',
|
||||
'authentication',
|
||||
'session-secret'
|
||||
].includes(probe.id);
|
||||
const show = isRedundant ? options.showRedundant : options.showNovel;
|
||||
if (!show) { return null; }
|
||||
const req = this._checks.requestCheck(probe);
|
||||
|
||||
@@ -8,7 +8,8 @@ export type BootProbeIds =
|
||||
'sandboxing' |
|
||||
'system-user' |
|
||||
'authentication' |
|
||||
'websockets'
|
||||
'websockets' |
|
||||
'session-secret'
|
||||
;
|
||||
|
||||
export interface BootProbeResult {
|
||||
|
||||
@@ -29,6 +29,9 @@ export class User extends BaseEntity {
|
||||
@Column({name: 'first_login_at', type: Date, nullable: true})
|
||||
public firstLoginAt: Date | null;
|
||||
|
||||
@Column({name: 'last_connection_at', type: Date, nullable: true})
|
||||
public lastConnectionAt: Date | null;
|
||||
|
||||
@OneToOne(type => Organization, organization => organization.owner)
|
||||
public personalOrg: Organization;
|
||||
|
||||
|
||||
@@ -395,14 +395,6 @@ export class UsersManager {
|
||||
user.name = (profile && (profile.name || email.split('@')[0])) || '';
|
||||
needUpdate = true;
|
||||
}
|
||||
if (profile && !user.firstLoginAt) {
|
||||
// set first login time to now (remove milliseconds for compatibility with other
|
||||
// timestamps in db set by typeorm, and since second level precision is fine)
|
||||
const nowish = new Date();
|
||||
nowish.setMilliseconds(0);
|
||||
user.firstLoginAt = nowish;
|
||||
needUpdate = true;
|
||||
}
|
||||
if (!user.picture && profile && profile.picture) {
|
||||
// Set the user's profile picture if our provider knows it.
|
||||
user.picture = profile.picture;
|
||||
@@ -432,6 +424,25 @@ export class UsersManager {
|
||||
user.options = {...(user.options ?? {}), authSubject: userOptions.authSubject};
|
||||
needUpdate = true;
|
||||
}
|
||||
|
||||
// get date of now (remove milliseconds for compatibility with other
|
||||
// timestamps in db set by typeorm, and since second level precision is fine)
|
||||
const nowish = new Date();
|
||||
nowish.setMilliseconds(0);
|
||||
if (profile && !user.firstLoginAt) {
|
||||
// set first login time to now
|
||||
user.firstLoginAt = nowish;
|
||||
needUpdate = true;
|
||||
}
|
||||
const getTimestampStartOfDay = (date: Date) => {
|
||||
const timestamp = Math.floor(date.getTime() / 1000); // unix timestamp seconds from epoc
|
||||
const startOfDay = timestamp - (timestamp % 86400 /*24h*/); // start of a day in seconds since epoc
|
||||
return startOfDay;
|
||||
};
|
||||
if (!user.lastConnectionAt || getTimestampStartOfDay(user.lastConnectionAt) !== getTimestampStartOfDay(nowish)) {
|
||||
user.lastConnectionAt = nowish;
|
||||
needUpdate = true;
|
||||
}
|
||||
if (needUpdate) {
|
||||
login.user = user;
|
||||
await manager.save([user, login]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {User} from 'app/gen-server/entity/User';
|
||||
import {makeId} from 'app/server/lib/idUtils';
|
||||
import {chunk} from 'lodash';
|
||||
import {MigrationInterface, QueryRunner, TableColumn} from "typeorm";
|
||||
|
||||
export class UserUUID1663851423064 implements MigrationInterface {
|
||||
@@ -16,11 +16,20 @@ export class UserUUID1663851423064 implements MigrationInterface {
|
||||
// Updating so many rows in a multiple queries is not ideal. We will send updates in chunks.
|
||||
// 300 seems to be a good number, for 24k rows we have 80 queries.
|
||||
const userList = await queryRunner.manager.createQueryBuilder()
|
||||
.select("users")
|
||||
.from(User, "users")
|
||||
.select(["users.id", "users.ref"])
|
||||
.from("users", "users")
|
||||
.getMany();
|
||||
userList.forEach(u => u.ref = makeId());
|
||||
await queryRunner.manager.save(userList, { chunk: 300 });
|
||||
|
||||
const userChunks = chunk(userList, 300);
|
||||
for (const users of userChunks) {
|
||||
await queryRunner.connection.transaction(async manager => {
|
||||
const queries = users.map((user: any, _index: number, _array: any[]) => {
|
||||
return queryRunner.manager.update("users", user.id, user);
|
||||
});
|
||||
await Promise.all(queries);
|
||||
});
|
||||
}
|
||||
|
||||
// We are not making this column unique yet, because it can fail
|
||||
// if there are some old workers still running, and any new user
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {User} from 'app/gen-server/entity/User';
|
||||
import {makeId} from 'app/server/lib/idUtils';
|
||||
import {chunk} from 'lodash';
|
||||
import {MigrationInterface, QueryRunner} from "typeorm";
|
||||
|
||||
export class UserRefUnique1664528376930 implements MigrationInterface {
|
||||
@@ -9,12 +9,21 @@ export class UserRefUnique1664528376930 implements MigrationInterface {
|
||||
|
||||
// Update users that don't have unique ref set.
|
||||
const userList = await queryRunner.manager.createQueryBuilder()
|
||||
.select("users")
|
||||
.from(User, "users")
|
||||
.where("ref is null")
|
||||
.getMany();
|
||||
.select(["users.id", "users.ref"])
|
||||
.from("users", "users")
|
||||
.where("users.ref is null")
|
||||
.getMany();
|
||||
userList.forEach(u => u.ref = makeId());
|
||||
await queryRunner.manager.save(userList, {chunk: 300});
|
||||
|
||||
const userChunks = chunk(userList, 300);
|
||||
for (const users of userChunks) {
|
||||
await queryRunner.connection.transaction(async manager => {
|
||||
const queries = users.map((user: any, _index: number, _array: any[]) => {
|
||||
return queryRunner.manager.update("users", user.id, user);
|
||||
});
|
||||
await Promise.all(queries);
|
||||
});
|
||||
}
|
||||
|
||||
// Mark column as unique and non-nullable.
|
||||
const users = (await queryRunner.getTable('users'))!;
|
||||
|
||||
18
app/gen-server/migration/1713186031023-UserLastConnection.ts
Normal file
18
app/gen-server/migration/1713186031023-UserLastConnection.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {MigrationInterface, QueryRunner, TableColumn} from 'typeorm';
|
||||
|
||||
export class UserLastConnection1713186031023 implements MigrationInterface {
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<any> {
|
||||
const sqlite = queryRunner.connection.driver.options.type === 'sqlite';
|
||||
const datetime = sqlite ? "datetime" : "timestamp with time zone";
|
||||
await queryRunner.addColumn('users', new TableColumn({
|
||||
name: 'last_connection_at',
|
||||
type: datetime,
|
||||
isNullable: true
|
||||
}));
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<any> {
|
||||
await queryRunner.dropColumn('users', 'last_connection_at');
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { GristServer } from 'app/server/lib/GristServer';
|
||||
import * as express from 'express';
|
||||
import WS from 'ws';
|
||||
import fetch from 'node-fetch';
|
||||
import { DEFAULT_SESSION_SECRET } from 'app/server/lib/coreCreator';
|
||||
|
||||
/**
|
||||
* Self-diagnostics useful when installing Grist.
|
||||
@@ -61,6 +62,7 @@ export class BootProbes {
|
||||
this._probes.push(_sandboxingProbe);
|
||||
this._probes.push(_authenticationProbe);
|
||||
this._probes.push(_webSocketsProbe);
|
||||
this._probes.push(_sessionSecretProbe);
|
||||
this._probeById = new Map(this._probes.map(p => [p.id, p]));
|
||||
}
|
||||
}
|
||||
@@ -284,3 +286,17 @@ const _authenticationProbe: Probe = {
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const _sessionSecretProbe: Probe = {
|
||||
id: 'session-secret',
|
||||
name: 'Session secret',
|
||||
apply: async(server, req) => {
|
||||
const usingDefaultSessionSecret = server.create.sessionSecret() === DEFAULT_SESSION_SECRET;
|
||||
return {
|
||||
status: usingDefaultSessionSecret ? 'warning' : 'success',
|
||||
details: {
|
||||
"GRIST_SESSION_SECRET": process.env.GRIST_SESSION_SECRET ? "set" : "not set",
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1883,6 +1883,22 @@ export class FlexServer implements GristServer {
|
||||
const probes = new BootProbes(this.app, this, '/api', adminMiddleware);
|
||||
probes.addEndpoints();
|
||||
|
||||
this.app.post('/api/admin/restart', requireInstallAdmin, expressWrap(async (req, resp) => {
|
||||
const newConfig = req.body.newConfig;
|
||||
resp.on('finish', () => {
|
||||
// If we have IPC with parent process (e.g. when running under
|
||||
// Docker) tell the parent that we have a new environment so it
|
||||
// can restart us.
|
||||
if (process.send) {
|
||||
process.send({ action: 'restart', newConfig });
|
||||
}
|
||||
});
|
||||
// On the topic of http response codes, thus spake MDN:
|
||||
// "409: This response is sent when a request conflicts with the current state of the server."
|
||||
const status = process.send ? 200 : 409;
|
||||
return resp.status(status).send();
|
||||
}));
|
||||
|
||||
// Restrict this endpoint to install admins
|
||||
this.app.get('/api/install/prefs', requireInstallAdmin, expressWrap(async (_req, resp) => {
|
||||
const activation = await this._activations.current();
|
||||
|
||||
@@ -3,11 +3,14 @@ import { checkMinIOBucket, checkMinIOExternalStorage,
|
||||
import { makeSimpleCreator } from 'app/server/lib/ICreate';
|
||||
import { Telemetry } from 'app/server/lib/Telemetry';
|
||||
|
||||
export const DEFAULT_SESSION_SECRET =
|
||||
'Phoo2ag1jaiz6Moo2Iese2xoaphahbai3oNg7diemohlah0ohtae9iengafieS2Hae7quungoCi9iaPh';
|
||||
|
||||
export const makeCoreCreator = () => makeSimpleCreator({
|
||||
deploymentType: 'core',
|
||||
// This can and should be overridden by GRIST_SESSION_SECRET
|
||||
// (or generated randomly per install, like grist-omnibus does).
|
||||
sessionSecret: 'Phoo2ag1jaiz6Moo2Iese2xoaphahbai3oNg7diemohlah0ohtae9iengafieS2Hae7quungoCi9iaPh',
|
||||
sessionSecret: DEFAULT_SESSION_SECRET,
|
||||
storage: [
|
||||
{
|
||||
name: 'minio',
|
||||
|
||||
@@ -6,10 +6,10 @@ import {GristServer} from 'app/server/lib/GristServer';
|
||||
import {fromCallback} from 'app/server/lib/serverUtils';
|
||||
import {Sessions} from 'app/server/lib/Sessions';
|
||||
import {promisifyAll} from 'bluebird';
|
||||
import * as crypto from 'crypto';
|
||||
import * as express from 'express';
|
||||
import assignIn = require('lodash/assignIn');
|
||||
import * as path from 'path';
|
||||
import * as shortUUID from "short-uuid";
|
||||
|
||||
|
||||
export const cookieName = process.env.GRIST_SESSION_COOKIE || 'grist_sid';
|
||||
@@ -118,7 +118,10 @@ export function initGristSessions(instanceRoot: string, server: GristServer) {
|
||||
// cookie could be stolen (with some effort) by the custom domain's owner, we limit the damage
|
||||
// by only honoring custom-domain cookies for requests to that domain.
|
||||
const generateId = (req: RequestWithOrg) => {
|
||||
const uid = shortUUID.generate();
|
||||
// Generate 256 bits of cryptographically random data to use as the session ID.
|
||||
// This ensures security against brute-force session hijacking even without signing the session ID.
|
||||
const randomNumbers = crypto.getRandomValues(new Uint8Array(32));
|
||||
const uid = Buffer.from(randomNumbers).toString("hex");
|
||||
return req.isCustomHost ? `c-${uid}@${req.org}@${req.get('host')}` : `g-${uid}`;
|
||||
};
|
||||
const sessionSecret = server.create.sessionSecret();
|
||||
|
||||
@@ -21,8 +21,8 @@ export const TEST_HTTPS_OFFSET = process.env.GRIST_TEST_HTTPS_OFFSET ?
|
||||
|
||||
// Database fields that we permit in entities but don't want to cross the api.
|
||||
const INTERNAL_FIELDS = new Set([
|
||||
'apiKey', 'billingAccountId', 'firstLoginAt', 'filteredOut', 'ownerId', 'gracePeriodStart', 'stripeCustomerId',
|
||||
'stripeSubscriptionId', 'stripeProductId', 'userId', 'isFirstTimeUser', 'allowGoogleLogin',
|
||||
'apiKey', 'billingAccountId', 'firstLoginAt', 'lastConnectionAt', 'filteredOut', 'ownerId', 'gracePeriodStart',
|
||||
'stripeCustomerId', 'stripeSubscriptionId', 'stripeProductId', 'userId', 'isFirstTimeUser', 'allowGoogleLogin',
|
||||
'authSubject', 'usage', 'createdBy'
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user